From 211e44ab63cda92e738027529a740de3f9bb203d Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Fri, 25 Sep 2026 13:00:26 +0300 Subject: [PATCH] feat(carousel): add floating buttons and bounded navigation #528 --- .../carousel-content.component.ts | 175 +++++-- .../carousel-indicators.component.html | 4 +- .../carousel-indicators.component.ts | 6 +- .../carousel-navigation.component.html | 63 ++- .../carousel-navigation.component.scss | 14 + .../carousel-navigation.component.ts | 20 +- .../content/carousel/carousel.component.html | 5 +- .../content/carousel/carousel.component.scss | 6 + .../carousel/carousel.component.spec.ts | 460 +++++++++++++++++- .../content/carousel/carousel.stories.ts | 68 ++- 10 files changed, 765 insertions(+), 56 deletions(-) diff --git a/src/tedi/components/content/carousel/carousel-content/carousel-content.component.ts b/src/tedi/components/content/carousel/carousel-content/carousel-content.component.ts index 8285b6a25..6a3bd0c86 100644 --- a/src/tedi/components/content/carousel/carousel-content/carousel-content.component.ts +++ b/src/tedi/components/content/carousel/carousel-content/carousel-content.component.ts @@ -12,6 +12,9 @@ import { OnDestroy, input, inject, + booleanAttribute, + effect, + untracked, HostListener, } from "@angular/core"; import { NgTemplateOutlet } from "@angular/common"; @@ -24,6 +27,9 @@ import { } from "../../../../services/breakpoint/breakpoint.service"; import { TediTranslationService } from "../../../../services"; +/** Ignore tiny calculation differences when deciding the carousel’s position. */ +const EPSILON = 0.001; + @Component({ standalone: true, selector: "tedi-carousel-content", @@ -60,6 +66,13 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { /** Transition duration in ms */ readonly transitionMs = input(400); + /** + * Whether navigation wraps around at the ends. When `false`, it stops at the + * first and last reachable positions and disables the corresponding arrow. + * @default true + */ + readonly loop = input(true, { transform: booleanAttribute }); + /** Accessible label for the carousel region. Falls back to the `carousel` translation. */ readonly ariaLabel = input(); @@ -140,8 +153,35 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { } }); - readonly buffer = computed(() => this.slides().length); + readonly buffer = computed(() => (this.loop() ? this.slides().length : 0)); + + /** Furthest track position in bounded mode; may be fractional to show the last slide fully. */ + readonly maxIndex = computed(() => + Math.max(0, this.slides().length - this.currentSlidesPerView()), + ); + + /** Number of indicator positions: one per slide when looping, or one per reachable stop when bounded. */ + readonly positionCount = computed(() => { + if (this.loop() || !this.slides().length) { + return this.slides().length; + } + + return Math.ceil(this.maxIndex() - EPSILON) + 1; + }); + + /** Index of the active indicator; a fractional final stop gets its own index. */ + readonly activePosition = computed(() => { + if (this.loop()) { + return this.slideIndex(); + } + + const nearest = this.snap(this.trackIndex()); + return nearest >= this.maxIndex() - EPSILON + ? this.positionCount() - 1 + : Math.round(nearest); + }); + /** Index of the leftmost visible slide, skipping a partially visible one in bounded mode. */ readonly slideIndex = computed(() => { const slidesCount = this.slides().length; @@ -149,14 +189,31 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { return 0; } + if (!this.loop()) { + return Math.min( + Math.max(Math.ceil(this.trackIndex() - EPSILON), 0), + slidesCount - 1, + ); + } + const i = Math.floor(this.trackIndex()); return ((i % slidesCount) + slidesCount) % slidesCount; }); readonly renderedActiveIndex = computed(() => { + if (!this.loop()) { + return this.trackIndex(); + } + return this.trackIndex() - this.windowBase() + this.buffer(); }); + readonly canPrev = computed(() => this.loop() || this.trackIndex() > EPSILON); + + readonly canNext = computed( + () => this.loop() || this.trackIndex() < this.maxIndex() - EPSILON, + ); + /** * 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. @@ -177,6 +234,10 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { return []; } + if (!this.loop()) { + return Array.from({ length: slidesCount }, (_, i) => i); + } + const total = 2 * this.buffer() + Math.ceil(this.currentSlidesPerView()); const start = this.windowBase() - this.buffer(); @@ -220,7 +281,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { const totalGapWidth = gap * (slidesPerView - 1); const slideWidth = (viewportWidth - totalGapWidth) / slidesPerView; - const offsetSlides = this.trackIndex() - this.windowBase() + this.buffer(); + const offsetSlides = this.renderedActiveIndex(); const translateX = -offsetSlides * (slideWidth + gap); return { @@ -232,6 +293,17 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { }; }); + constructor() { + // Re-clamp without animation when bounded mode starts or its limit changes. + // Don't track the current position: normal navigation must not rerun this effect. + effect(() => { + if (!this.loop()) { + const max = this.maxIndex(); + untracked(() => this.clampIntoRange(max)); + } + }); + } + locked = false; dragging = false; private pendingFocus = false; @@ -282,9 +354,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { const deltaSlides = delta / cellWidth; this.scrollDelta += deltaSlides; - const maxDelta = this.buffer() * 0.9; - const min = this.windowBase() - maxDelta; - const max = this.windowBase() + maxDelta; + const { min, max } = this.dragBounds(this.windowBase()); const unclamped = this.trackIndex() + deltaSlides; const clamped = Math.min(Math.max(unclamped, min), max); @@ -298,18 +368,21 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.wheelTimeout = setTimeout(() => { this.animate.set(true); + // Recalculate bounds: the viewport or slide count may have changed before snapping. + const { min, max } = this.dragBounds(this.windowBase()); const direction = Math.sign(this.scrollDelta); const current = this.trackIndex(); - let snapIndex = Math.round(current); + let snapIndex = this.snap(current); if (Math.abs(this.scrollDelta) > 0.3) { snapIndex = direction > 0 ? Math.ceil(current) : Math.floor(current); } + // If scrolling hit a limit, snap inward when looping or to the exact bound otherwise. if (wasClamped) { - if (current <= min) snapIndex = Math.ceil(min); - if (current >= max) snapIndex = Math.floor(max); + if (current <= min) snapIndex = this.loop() ? Math.ceil(min) : min; + if (current >= max) snapIndex = this.loop() ? Math.floor(max) : max; } const finalIndex = Math.min(Math.max(snapIndex, min), max); @@ -360,7 +433,10 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.animate.set(false); this.startX = ev.clientX; this.startIndex = this.trackIndex(); - this.windowBase.set(Math.floor(this.startIndex)); + + if (this.loop()) { + this.windowBase.set(Math.floor(this.startIndex)); + } } @HostListener("pointermove", ["$event"]) @@ -383,9 +459,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { 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 { min, max } = this.dragBounds(Math.floor(this.startIndex)); const clamped = Math.min(Math.max(targetIndex, min), max); this.trackIndex.set(clamped); } @@ -400,15 +474,16 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.dragging = false; this.animate.set(true); - this.trackIndex.set(Math.round(this.trackIndex())); + this.trackIndex.set(this.snap(this.trackIndex())); } ngAfterViewInit(): void { const viewport = this.host.nativeElement; this.viewportWidth.set(viewport.clientWidth); - this.ro = new ResizeObserver(() => { - this.viewportWidth.set(viewport.clientWidth); + // Use the fractional width to keep track translation aligned with CSS-sized slides. + this.ro = new ResizeObserver(([entry]) => { + this.viewportWidth.set(entry.contentRect.width); }); this.ro.observe(viewport); @@ -419,23 +494,24 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { } next(): void { - if (!this.slides().length || this.locked) { + if (!this.slides().length || this.locked || !this.canNext()) { return; } this.animate.set(true); - this.trackIndex.update((i) => i + 1); + // Move to the next whole position, clamping to a fractional final position. + this.trackIndex.update((i) => this.clampIndex(Math.floor(i + EPSILON) + 1)); this.lockNavigation(); this.announceSlideChange(); } prev(): void { - if (!this.slides().length || this.locked) { + if (!this.slides().length || this.locked || !this.canPrev()) { return; } this.animate.set(true); - this.trackIndex.update((i) => i - 1); + this.trackIndex.update((i) => this.clampIndex(Math.ceil(i - EPSILON) - 1)); this.lockNavigation(); this.announceSlideChange(); } @@ -447,11 +523,16 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { 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); + + if (this.loop()) { + const current = this.slideIndex(); + const normalized = ((index % slidesCount) + slidesCount) % slidesCount; + const delta = normalized - current; + this.trackIndex.update((i) => i + delta); + } else { + this.trackIndex.set(this.clampIndex(index)); + } if (options?.focusSlide) { // Focus after transition completes so DOM positions are stable @@ -467,7 +548,8 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { */ focusActiveSlide(): void { setTimeout(() => { - const activeIndex = this.renderedActiveIndex(); + // A bounded track can rest at a fractional index; focus the first fully visible slide. + const activeIndex = Math.ceil(this.renderedActiveIndex() - EPSILON); const slideElement = this.slideElements()[activeIndex]; if (slideElement) { slideElement.nativeElement.focus({ preventScroll: true }); @@ -493,15 +575,52 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { } } + private clampIntoRange(max: number): void { + const current = this.trackIndex(); + const clamped = Math.min(Math.max(current, 0), max); + + if (clamped !== current) { + this.animate.set(false); + this.trackIndex.set(clamped); + this.windowBase.set(Math.floor(clamped)); + } + } + + private clampIndex(value: number): number { + return this.loop() ? value : Math.min(Math.max(value, 0), this.maxIndex()); + } + + /** Snap to the nearest reachable position, including a fractional end. */ + private snap(value: number): number { + if (this.loop()) { + return Math.round(value); + } + + const below = this.clampIndex(Math.floor(value)); + const above = this.clampIndex(Math.ceil(value)); + + return value - below <= above - value ? below : above; + } + + /** + * Range the track may be dragged or wheeled within. A looping track stays inside + * its rendered duplicate buffer around `base`; a bounded one stops at the ends. + */ + private dragBounds(base: number): { min: number; max: number } { + if (!this.loop()) { + return { min: 0, max: this.maxIndex() }; + } + + const maxDelta = this.buffer() * 0.9; + + return { min: base - maxDelta, max: base + maxDelta }; + } + 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; diff --git a/src/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html b/src/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html index 83b4c0be4..a9bba0500 100644 --- a/src/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html +++ b/src/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html @@ -4,6 +4,7 @@ type="button" variant="neutral" [attr.aria-label]="translationService.track('carousel.moveBack')()" + [disabled]="!carousel.carouselContent().canPrev()" (click)="handlePrev()" > @@ -22,7 +23,7 @@ (click)="handleIndicatorClick(indicator.index)" > } -} @else if (activeSlideNumber()) { +} @else if (indicatorsArray().length) {
{{ activeSlideNumber() }} / {{ indicatorsArray().length }} @@ -35,6 +36,7 @@ type="button" variant="neutral" [attr.aria-label]="translationService.track('carousel.moveForward')()" + [disabled]="!carousel.carouselContent().canNext()" (click)="handleNext()" > diff --git a/src/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts b/src/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts index 23c6000f6..4083cb097 100644 --- a/src/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts +++ b/src/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts @@ -36,16 +36,16 @@ export class CarouselIndicatorsComponent { readonly indicatorsArray = computed(() => Array.from( - { length: this.carousel.carouselContent().slides().length }, + { length: this.carousel.carouselContent().positionCount() }, (_, i) => ({ index: i, - active: this.carousel.carouselContent().slideIndex() === i, + active: this.carousel.carouselContent().activePosition() === i, }), ), ); readonly activeSlideNumber = computed( - () => this.carousel.carouselContent().slideIndex() + 1, + () => this.carousel.carouselContent().activePosition() + 1, ); handleNext() { diff --git a/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html b/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html index c0f1019b1..59eabe9e8 100644 --- a/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html +++ b/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html @@ -1,18 +1,45 @@ - - +@if (overlay()) { + + +} @else { + + +} diff --git a/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss b/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss index 16cb10b69..c4fabdfe3 100644 --- a/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss +++ b/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss @@ -2,4 +2,18 @@ tedi-carousel-navigation { display: flex; gap: var(--layout-grid-gutters-08); align-items: center; + + &.tedi-carousel-navigation--overlay { + position: absolute; + inset: 0; + z-index: 1; + box-sizing: border-box; + justify-content: space-between; + padding: var(--layout-grid-gutters-08); + pointer-events: none; + + > * { + pointer-events: auto; + } + } } diff --git a/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts b/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts index 309bb60b7..bd05d4798 100644 --- a/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts +++ b/src/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts @@ -1,10 +1,13 @@ import { + booleanAttribute, ChangeDetectionStrategy, Component, + computed, inject, + input, ViewEncapsulation, } from "@angular/core"; -import { ButtonComponent } from "../../../buttons"; +import { ButtonComponent, FloatingButtonComponent } from "../../../buttons"; import { IconComponent } from "../../../base"; import { CarouselComponent } from "../carousel.component"; import { TediTranslationService } from "../../../../services"; @@ -16,12 +19,25 @@ import { TediTranslationService } from "../../../../services"; styleUrl: "./carousel-navigation.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, - imports: [ButtonComponent, IconComponent], + imports: [ButtonComponent, FloatingButtonComponent, IconComponent], + host: { + "[class.tedi-carousel-navigation--overlay]": "overlay()", + }, }) export class CarouselNavigationComponent { + /** + * Shows floating navigation buttons over the slides. Place navigation directly + * inside `tedi-carousel`, outside the header and footer. + * @default false + */ + readonly overlay = input(false, { transform: booleanAttribute }); + readonly translationService = inject(TediTranslationService); private readonly carousel = inject(CarouselComponent); + readonly canPrev = computed(() => this.carousel.carouselContent().canPrev()); + readonly canNext = computed(() => this.carousel.carouselContent().canNext()); + handleNext() { this.carousel.carouselContent().next(); } diff --git a/src/tedi/components/content/carousel/carousel.component.html b/src/tedi/components/content/carousel/carousel.component.html index ef1258231..d0634ccfe 100644 --- a/src/tedi/components/content/carousel/carousel.component.html +++ b/src/tedi/components/content/carousel/carousel.component.html @@ -1,3 +1,6 @@ - + diff --git a/src/tedi/components/content/carousel/carousel.component.scss b/src/tedi/components/content/carousel/carousel.component.scss index 926406db2..684c56d28 100644 --- a/src/tedi/components/content/carousel/carousel.component.scss +++ b/src/tedi/components/content/carousel/carousel.component.scss @@ -3,3 +3,9 @@ tedi-carousel { flex-direction: column; gap: var(--layout-grid-gutters-08); } + +.tedi-carousel__body { + position: relative; + display: flex; + flex-direction: column; +} diff --git a/src/tedi/components/content/carousel/carousel.component.spec.ts b/src/tedi/components/content/carousel/carousel.component.spec.ts index 018356a43..ed996159d 100644 --- a/src/tedi/components/content/carousel/carousel.component.spec.ts +++ b/src/tedi/components/content/carousel/carousel.component.spec.ts @@ -123,6 +123,19 @@ describe("CarouselContentComponent", () => { expect(style.transition).toBe("none"); }); + it("uses the fractional observed width so the track does not drift by sub-pixels", () => { + const ro = component["ro"] as unknown as { + callback: ResizeObserverCallback; + }; + + ro.callback( + [{ contentRect: { width: 1422.39 } } as ResizeObserverEntry], + ro as unknown as ResizeObserver, + ); + + expect(component.viewportWidth()).toBe(1422.39); + }); + it("should not fail if ngOnDestroy called without ResizeObserver", () => { expect(() => component.ngOnDestroy()).not.toThrow(); }); @@ -789,6 +802,31 @@ describe("CarouselContentComponent", () => { }); }); + describe("when looping with slides", () => { + beforeEach(() => { + // Fresh fixture so the slide stub is in place before the first render. + fixture = TestBed.createComponent(CarouselContentComponent); + component = fixture.componentInstance; + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + fixture.detectChanges(); + }); + + it("renders a duplicate buffer of slides on both sides of the view", () => { + // 3 slides before, 1 in view, 3 after. + expect(component.renderedIndices()).toEqual([0, 1, 2, 0, 1, 2, 0]); + }); + + it("uses the wrapped slide index as the active position", () => { + component.trackIndex.set(4); + + expect(component.activePosition()).toBe(1); + expect(component.positionCount()).toBe(3); + }); + }); + describe("pointer events", () => { it("should update trackIndex on pointermove when dragging", () => { Object.defineProperty(component, "slides", { @@ -1035,6 +1073,305 @@ describe("CarouselContentComponent", () => { }); }); + describe("loop", () => { + const withSlides = (count: number) => + Object.defineProperty(component, "slides", { + configurable: true, + value: () => Array.from({ length: count }, () => ({})), + }); + + it("loops by default, so both directions are always available", () => { + withSlides(5); + + expect(component.loop()).toBe(true); + expect(component.canPrev()).toBe(true); + expect(component.canNext()).toBe(true); + }); + + describe("when false", () => { + beforeEach(() => { + // Fresh fixture so the slide stub is in place before the first render. + fixture = TestBed.createComponent(CarouselContentComponent); + component = fixture.componentInstance; + withSlides(5); + fixture.componentRef.setInput("slidesPerView", { xs: 3 }); + fixture.componentRef.setInput("loop", false); + fixture.detectChanges(); + }); + + it("stops at the last position where the final slide is fully in view", () => { + expect(component.maxIndex()).toBe(2); + }); + + it("disables prev at the start and next at the end", () => { + expect(component.canPrev()).toBe(false); + expect(component.canNext()).toBe(true); + + component.trackIndex.set(2); + + expect(component.canPrev()).toBe(true); + expect(component.canNext()).toBe(false); + }); + + it("does not move past either bound with next() / prev()", () => { + component.prev(); + expect(component.trackIndex()).toBe(0); + + component.trackIndex.set(2); + component.next(); + expect(component.trackIndex()).toBe(2); + }); + + it("steps one slide at a time within the bounds", () => { + component.next(); + expect(component.trackIndex()).toBe(1); + }); + + it("renders each slide exactly once, without loop duplicates", () => { + expect(component.renderedIndices()).toEqual([0, 1, 2, 3, 4]); + }); + + it("uses the track index as the rendered active index", () => { + component.trackIndex.set(2); + expect(component.renderedActiveIndex()).toBe(2); + expect(component.slideIndex()).toBe(2); + }); + + it("clamps goToIndex to the last reachable position", () => { + component.goToIndex(4); + expect(component.trackIndex()).toBe(2); + }); + + it("clamps dragging to the bounds", () => { + component.viewportWidth.set(1000); + component.dragging = true; + component["startX"] = 500; + component["startIndex"] = 0; + + component.onPointerMove({ clientX: 900 } as PointerEvent); + + expect(component.trackIndex()).toBe(0); + + component.onPointerMove({ clientX: -5000 } as PointerEvent); + + expect(component.trackIndex()).toBe(2); + }); + + it("has one stop position per reachable index", () => { + expect(component.positionCount()).toBe(3); + + component.trackIndex.set(2); + + expect(component.activePosition()).toBe(2); + }); + + it("clamps wheel scrolling to the bounds", () => { + component.viewportWidth.set(1000); + component.onWheel(new WheelEvent("wheel", { deltaX: -2000 })); + + expect(component.trackIndex()).toBe(0); + + component.onWheel(new WheelEvent("wheel", { deltaX: 10000 })); + + expect(component.trackIndex()).toBe(2); + }); + }); + }); + + describe("loop false with fractional slidesPerView", () => { + beforeEach(() => { + fixture = TestBed.createComponent(CarouselContentComponent); + component = fixture.componentInstance; + Object.defineProperty(component, "slides", { + configurable: true, + value: () => Array.from({ length: 5 }, () => ({})), + }); + fixture.componentRef.setInput("slidesPerView", { xs: 2.5 }); + fixture.componentRef.setInput("loop", false); + fixture.detectChanges(); + }); + + it("ends where the last slide is fully in view", () => { + expect(component.maxIndex()).toBe(2.5); + expect(component.positionCount()).toBe(4); + }); + + it("takes a partial final step and then stops", () => { + const steps: number[] = []; + for (let i = 0; i < 4; i++) { + component.locked = false; + component.next(); + steps.push(component.trackIndex()); + } + + expect(steps).toEqual([1, 2, 2.5, 2.5]); + expect(component.canNext()).toBe(false); + expect(component.activePosition()).toBe(3); + expect(component.slideIndex()).toBe(3); + }); + + it("steps back from the fractional end onto whole positions", () => { + component.trackIndex.set(2.5); + component.prev(); + + expect(component.trackIndex()).toBe(2); + }); + + it("goes to the fractional end for the last position and the End key", () => { + component.goToIndex(3); + expect(component.trackIndex()).toBe(2.5); + + component.trackIndex.set(0); + component.onKeyDown(new KeyboardEvent("keydown", { key: "End" })); + expect(component.trackIndex()).toBe(2.5); + }); + + it.each([ + [2.2, 2], + [2.3, 2.5], + [0.4, 0], + [0.6, 1], + ])("snaps a drag released at %s to %s", (released, expected) => { + component.dragging = true; + component.trackIndex.set(released); + component.onPointerUp(); + + expect(component.trackIndex()).toBe(expected); + }); + + it("stays at the fractional end when wheel scrolling past it", () => { + jest.useFakeTimers(); + component.viewportWidth.set(1000); + component.trackIndex.set(2); + component.onWheel(new WheelEvent("wheel", { deltaX: 10000 })); + jest.advanceTimersByTime(200); + + expect(component.trackIndex()).toBe(2.5); + jest.useRealTimers(); + }); + }); + + describe("loop false with a small final step", () => { + beforeEach(() => { + fixture = TestBed.createComponent(CarouselContentComponent); + component = fixture.componentInstance; + Object.defineProperty(component, "slides", { + configurable: true, + value: () => Array.from({ length: 5 }, () => ({})), + }); + fixture.componentRef.setInput("slidesPerView", { xs: 2.9 }); + fixture.componentRef.setInput("loop", false); + fixture.detectChanges(); + }); + + it("marks the last position active at the fractional end", () => { + component.goToIndex(3); + + expect(component.trackIndex()).toBeCloseTo(2.1); + expect(component.positionCount()).toBe(4); + expect(component.activePosition()).toBe(3); + }); + + it("keeps the whole position active until the end is nearer", () => { + component.trackIndex.set(2.04); + expect(component.activePosition()).toBe(2); + + component.trackIndex.set(2.06); + expect(component.activePosition()).toBe(3); + }); + }); + + describe("loop false when the bound shrinks", () => { + const slideCount = signal(5); + + beforeEach(() => { + slideCount.set(5); + fixture = TestBed.createComponent(CarouselContentComponent); + component = fixture.componentInstance; + Object.defineProperty(component, "slides", { + configurable: true, + value: () => Array.from({ length: slideCount() }, () => ({})), + }); + fixture.componentRef.setInput("slidesPerView", { xs: 1 }); + fixture.componentRef.setInput("loop", false); + fixture.detectChanges(); + component.trackIndex.set(4); + }); + + it("moves back into range when more slides fit in view", () => { + // E.g. a phone at 1 per view resized to a desktop breakpoint at 4 per view. + fixture.componentRef.setInput("slidesPerView", { xs: 4 }); + fixture.detectChanges(); + + expect(component.maxIndex()).toBe(1); + expect(component.trackIndex()).toBe(1); + expect(component.canNext()).toBe(false); + }); + + it("moves back into range when slides are removed", () => { + slideCount.set(3); + fixture.detectChanges(); + + expect(component.trackIndex()).toBe(2); + }); + + it("jumps without animating", () => { + component.animate.set(true); + fixture.componentRef.setInput("slidesPerView", { xs: 4 }); + fixture.detectChanges(); + + expect(component.animate()).toBe(false); + }); + + it("keeps a pending wheel snap within the new bound", () => { + jest.useFakeTimers(); + component.viewportWidth.set(1000); + component.trackIndex.set(3); + // Half a slide forwards: the pending snap would round up to 4. + component.onWheel(new WheelEvent("wheel", { deltaX: 500 })); + + // Before the snap fires, the bound shrinks to a fractional end. + fixture.componentRef.setInput("slidesPerView", { xs: 3.5 }); + fixture.detectChanges(); + expect(component.trackIndex()).toBe(1.5); + + jest.advanceTimersByTime(200); + + expect(component.trackIndex()).toBe(1.5); + jest.useRealTimers(); + }); + + it("leaves an in-range position alone", () => { + component.trackIndex.set(1); + fixture.componentRef.setInput("slidesPerView", { xs: 4 }); + fixture.detectChanges(); + + expect(component.trackIndex()).toBe(1); + }); + }); + + it("clamps into range when loop is switched off", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => Array.from({ length: 5 }, () => ({})), + }); + // A looping track can sit below 0 after navigating back from the first slide. + component.trackIndex.set(-2); + fixture.componentRef.setInput("loop", false); + fixture.detectChanges(); + + expect(component.trackIndex()).toBe(0); + }); + + it("has no stop positions without slides, looping or not", () => { + expect(component.positionCount()).toBe(0); + + fixture.componentRef.setInput("loop", false); + fixture.detectChanges(); + + expect(component.positionCount()).toBe(0); + }); + describe("navigation when no slides", () => { it("should not navigate next when no slides", () => { Object.defineProperty(component, "slides", { @@ -1140,6 +1477,10 @@ describe("CarouselIndicatorsComponent", () => { mockCarouselContent = { slides: jest.fn().mockReturnValue([{}, {}, {}]), slideIndex: jest.fn().mockReturnValue(1), + positionCount: signal(3), + activePosition: signal(1), + canPrev: signal(true), + canNext: signal(true), next: jest.fn(), prev: jest.fn(), goToIndex: jest.fn(), @@ -1210,14 +1551,67 @@ describe("CarouselIndicatorsComponent", () => { focusSlide: true, }); }); + + it("renders one dot per stop position and marks the active one", () => { + // A bounded carousel with 5 slides, 3 in view, stops at 3 positions. + mockCarouselContent.positionCount.set(3); + mockCarouselContent.activePosition.set(2); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + const dots = Array.from( + fixture.nativeElement.querySelectorAll(".tedi-carousel__indicator"), + ); + + expect(dots.length).toBe(3); + expect(dots[2].classList).toContain("tedi-carousel__indicator--active"); + }); + + it("counts stop positions in the numbers variant", () => { + fixture.componentInstance.variant = "numbers"; + mockCarouselContent.positionCount.set(4); + mockCarouselContent.activePosition.set(3); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.replace(/\s+/g, "")).toContain( + "4/4", + ); + }); + + it("shows no numbers when there are no stop positions", () => { + fixture.componentInstance.variant = "numbers"; + mockCarouselContent.positionCount.set(0); + mockCarouselContent.activePosition.set(0); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toBe(""); + }); + + it("disables the arrows at the bounds of a non-looping carousel", () => { + fixture.componentInstance.withArrows = true; + mockCarouselContent.canPrev.set(false); + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + const [back, next] = Array.from( + fixture.nativeElement.querySelectorAll("button[tedi-button]"), + ); + + expect(back.disabled).toBe(true); + expect(next.disabled).toBe(false); + }); }); @Component({ standalone: true, imports: [CarouselNavigationComponent], - template: ` `, + template: ` `, }) -class TestNavigationHostComponent {} +class TestNavigationHostComponent { + overlay = signal(false); +} describe("CarouselNavigationComponent", () => { let fixture: ComponentFixture; @@ -1229,6 +1623,8 @@ describe("CarouselNavigationComponent", () => { beforeEach(async () => { mockCarouselContent = { + canPrev: signal(true), + canNext: signal(true), next: jest.fn(), prev: jest.fn(), }; @@ -1276,4 +1672,64 @@ describe("CarouselNavigationComponent", () => { component.handlePrev(); expect(mockCarouselContent.prev).toHaveBeenCalledTimes(1); }); + + const buttons = () => + Array.from( + fixture.nativeElement.querySelectorAll("button"), + ); + + it("renders regular secondary buttons by default", () => { + const [back, next] = buttons(); + + expect(back.classList).toContain("tedi-button"); + expect(next.classList).toContain("tedi-button"); + expect(back.getAttribute("aria-label")).toBe("carousel.moveBack"); + expect(next.getAttribute("aria-label")).toBe("carousel.moveForward"); + }); + + it("renders static secondary floating buttons when overlay is set", () => { + fixture.componentInstance.overlay.set(true); + fixture.detectChanges(); + + const host = fixture.nativeElement.querySelector( + "tedi-carousel-navigation", + ) as HTMLElement; + const [back, next] = buttons(); + + expect(host.classList).toContain("tedi-carousel-navigation--overlay"); + for (const button of [back, next]) { + expect(button.classList).toContain("tedi-floating-button"); + expect(button.classList).toContain("tedi-floating-button--secondary"); + expect(button.classList).toContain("tedi-floating-button--icon-only"); + expect(button.style.position).toBe("static"); + } + expect(back.getAttribute("aria-label")).toBe("carousel.moveBack"); + expect(next.getAttribute("aria-label")).toBe("carousel.moveForward"); + }); + + it.each([false, true])( + "disables the arrows at the bounds (overlay: %s)", + (overlay) => { + fixture.componentInstance.overlay.set(overlay); + mockCarouselContent.canNext.set(false); + fixture.detectChanges(); + + const [back, next] = buttons(); + + expect(back.disabled).toBe(false); + expect(next.disabled).toBe(true); + }, + ); + + it("wires the overlay buttons to prev / next", () => { + fixture.componentInstance.overlay.set(true); + fixture.detectChanges(); + + const [back, next] = buttons(); + back.click(); + next.click(); + + expect(mockCarouselContent.prev).toHaveBeenCalledTimes(1); + expect(mockCarouselContent.next).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/tedi/components/content/carousel/carousel.stories.ts b/src/tedi/components/content/carousel/carousel.stories.ts index be6abda71..f048a5e77 100644 --- a/src/tedi/components/content/carousel/carousel.stories.ts +++ b/src/tedi/components/content/carousel/carousel.stories.ts @@ -15,7 +15,7 @@ import { IconComponent } from "../../base/icon/icon.component"; import { ButtonComponent } from "../../buttons/button/button.component"; /** - * Figma ↗
+ * Figma ↗
*/ export default { @@ -96,6 +96,20 @@ export default { defaultValue: { summary: "400" }, }, }, + loop: { + description: + "Whether navigation wraps around at the ends. When `false`, it stops at the first and last reachable positions and disables the corresponding arrow.", + control: { + type: "boolean", + }, + table: { + category: "Carousel Content", + type: { + summary: "boolean", + }, + defaultValue: { summary: "true" }, + }, + }, ariaLabel: { name: "ariaLabel", description: @@ -106,6 +120,19 @@ export default { type: { summary: "string" }, }, }, + overlay: { + description: + "Shows floating navigation buttons over the slides. Place navigation directly inside `tedi-carousel`, outside the header and footer.", + // Documented only: without `overlay`, a navigation placed directly inside the carousel has no designed layout. + control: false, + table: { + category: "Carousel Navigation", + type: { + summary: "boolean", + }, + defaultValue: { summary: "false" }, + }, + }, withArrows: { description: "Should show indicators with arrows? If yes, don't use carousel-navigation component", @@ -141,6 +168,7 @@ type CarouselType = CarouselComponent & { gap: BreakpointInput; fade: boolean; transitionMs: number; + loop: boolean; withArrows: boolean; variant: CarouselIndicatorsVariant; }; @@ -378,6 +406,44 @@ export const CombinationsTopNavigationBottomDots: StoryObj = { }), }; +/** + * Add `overlay` to `tedi-carousel-navigation` and place it directly inside `tedi-carousel` + * to show the arrows as floating buttons over the slides. + */ +export const FloatingButtons: StoryObj = { + name: "Floating buttons", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + loop: true, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

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