Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
OnDestroy,
input,
inject,
booleanAttribute,
effect,
untracked,
HostListener,
} from "@angular/core";
import { NgTemplateOutlet } from "@angular/common";
Expand All @@ -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",
Expand Down Expand Up @@ -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<string>();

Expand Down Expand Up @@ -140,23 +153,67 @@ 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;

if (slidesCount === 0) {
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.
Expand All @@ -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();

Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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"])
Expand All @@ -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);
}
Expand All @@ -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);
Expand All @@ -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();
}
Expand All @@ -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
Expand All @@ -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 });
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
type="button"
variant="neutral"
[attr.aria-label]="translationService.track('carousel.moveBack')()"
[disabled]="!carousel.carouselContent().canPrev()"
(click)="handlePrev()"
>
<tedi-icon name="arrow_back" [size]="18" />
Expand All @@ -22,7 +23,7 @@
(click)="handleIndicatorClick(indicator.index)"
></button>
}
} @else if (activeSlideNumber()) {
} @else if (indicatorsArray().length) {
<div>
<b tedi-text color="brand">{{ activeSlideNumber() }}</b>
<span tedi-text color="tertiary"> / {{ indicatorsArray().length }}</span>
Expand All @@ -35,6 +36,7 @@
type="button"
variant="neutral"
[attr.aria-label]="translationService.track('carousel.moveForward')()"
[disabled]="!carousel.carouselContent().canNext()"
(click)="handleNext()"
>
<tedi-icon name="arrow_forward" [size]="18" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading