From 10089bf9082d973ff8b42be741dc1660e9720ff6 Mon Sep 17 00:00:00 2001 From: Rando Leppik Date: Fri, 25 Sep 2026 15:54:32 +0300 Subject: [PATCH] feat(carousel): add swipeThreshold input and improve swipe navigation #187 --- .../carousel-content.component.ts | 160 ++++++++- .../carousel/carousel.component.spec.ts | 315 +++++++++++++++++- .../content/carousel/carousel.stories.ts | 21 +- 3 files changed, 479 insertions(+), 17 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 6a3bd0c86..6d9581e51 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 @@ -13,6 +13,7 @@ import { input, inject, booleanAttribute, + numberAttribute, effect, untracked, HostListener, @@ -30,6 +31,15 @@ import { TediTranslationService } from "../../../../services"; /** Ignore tiny calculation differences when deciding the carousel’s position. */ const EPSILON = 0.001; +/** Drag speed, in slides per ms, that counts as a flick. */ +const FLICK_VELOCITY = 0.0015; + +/** A flick's speed is measured over this many ms before release. */ +const FLICK_WINDOW = 100; + +/** Drags shorter than this (px) never move the carousel, so taps and jitter stay put. */ +const MIN_SWIPE_DISTANCE = 10; + @Component({ standalone: true, selector: "tedi-carousel-content", @@ -66,6 +76,17 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { /** Transition duration in ms */ readonly transitionMs = input(400); + /** + * How far a drag must go to move on, as a fraction (0–1) of the distance to the next + * position, which is one slide except for a shorter final step when not looping. + * Each position fully dragged past counts; a quick flick moves one position. + * @default 0.3 + */ + readonly swipeThreshold = input(0.3, { + transform: (value: unknown) => + Math.min(Math.max(numberAttribute(value, 0.3), 0), 1), + }); + /** * Whether navigation wraps around at the ends. When `false`, it stops at the * first and last reachable positions and disables the corresponding arrow. @@ -309,6 +330,10 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { private pendingFocus = false; private startX = 0; private startIndex = 0; + private lastX = 0; + private lastTime = 0; + /** Recent pointer positions, for measuring flick speed at release. */ + private samples: { x: number; t: number }[] = []; private ro?: ResizeObserver; private wheelTimeout?: ReturnType; private scrollDelta = 0; @@ -433,6 +458,9 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.animate.set(false); this.startX = ev.clientX; this.startIndex = this.trackIndex(); + this.lastX = ev.clientX; + this.lastTime = ev.timeStamp; + this.samples = [{ x: ev.clientX, t: ev.timeStamp }]; if (this.loop()) { this.windowBase.set(Math.floor(this.startIndex)); @@ -446,6 +474,12 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { } const dx = ev.clientX - this.startX; + this.lastX = ev.clientX; + this.lastTime = ev.timeStamp; + this.samples.push({ x: ev.clientX, t: ev.timeStamp }); + while (this.samples[0].t < ev.timeStamp - FLICK_WINDOW) { + this.samples.shift(); + } const cellWidth = (this.viewportWidth() - this.currentGap() * (this.currentSlidesPerView() - 1)) / @@ -464,17 +498,137 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.trackIndex.set(clamped); } - @HostListener("pointerup") + /** + * The browser ended the drag, e.g. to scroll the page, or capture was lost mid-drag: + * not a swipe, so return to where it started. After a normal release this is a no-op, + * as `lostpointercapture` follows `pointerup`. + */ @HostListener("pointercancel") @HostListener("lostpointercapture") - onPointerUp() { + onPointerCancel() { + if (!this.dragging) { + return; + } + + this.dragging = false; + this.animate.set(true); + this.trackIndex.set(this.snap(this.startIndex)); + } + + @HostListener("pointerup", ["$event"]) + onPointerUp(ev?: PointerEvent) { if (!this.dragging) { return; } + // The pointer can move after the last pointermove; the release reports where it ended. + if (typeof ev?.clientX === "number") { + this.onPointerMove(ev); + } + this.dragging = false; this.animate.set(true); - this.trackIndex.set(this.snap(this.trackIndex())); + this.trackIndex.set(this.swipeTarget(ev?.timeStamp ?? this.lastTime)); + } + + /** + * Where a released drag settles. Walks stop position to stop position in the drag + * direction: each gap fully dragged moves on, and a partly dragged gap moves on once it + * reaches `swipeThreshold` of that gap (a bounded track's final gap can be short). + * A flick that did not move on otherwise moves one position. + */ + private swipeTarget(releasedAt: number): number { + const dx = this.lastX - this.startX; + + if (Math.abs(dx) < MIN_SWIPE_DISTANCE) { + return this.snap(this.startIndex); + } + + // Take the direction from the pointer: at a bound the clamped track does not move. + const forward = dx < 0; + let remaining = Math.abs(this.trackIndex() - this.startIndex); + let position = this.startIndex; + let moved = false; + + for (;;) { + const next = this.adjacentStop(position, forward); + const gap = Math.abs(next - position); + + if (gap < EPSILON) { + break; + } + + if (remaining >= gap - EPSILON) { + position = next; + remaining -= gap; + moved = true; + continue; + } + + if ( + remaining > EPSILON && + remaining >= gap * this.swipeThreshold() - EPSILON + ) { + position = next; + moved = true; + } + + break; + } + + if (!moved && this.isFlick(releasedAt)) { + return this.adjacentStop(this.startIndex, forward); + } + + return moved ? position : this.snap(this.startIndex); + } + + /** The next stop position after `position` in the given direction (itself at a bound). */ + private adjacentStop(position: number, forward: boolean): number { + return forward + ? this.clampIndex(Math.floor(position + EPSILON) + 1) + : this.clampIndex(Math.ceil(position - EPSILON) - 1); + } + + /** + * Whether the drag ended in a flick: at least `MIN_SWIPE_DISTANCE` of movement in the + * drag direction within the last `FLICK_WINDOW` ms before release, fast enough. Measuring + * up to the release lets a pause before the swipe pass and a hold after it slow it down. + */ + private isFlick(releasedAt: number): boolean { + // Unmeasured, the gap alone would give a tiny cell width and make any drag a flick. + if (!this.viewportWidth()) { + return false; + } + + const recent = this.samples.find( + (sample) => sample.t >= releasedAt - FLICK_WINDOW, + ); + + if (!recent) { + return false; + } + + const distance = this.lastX - recent.x; + const elapsed = releasedAt - recent.t; + const sameDirection = + Math.sign(distance) === Math.sign(this.lastX - this.startX); + + if ( + !(elapsed > 0) || + !sameDirection || + Math.abs(distance) < MIN_SWIPE_DISTANCE + ) { + return false; + } + + const cellWidth = + (this.viewportWidth() - + this.currentGap() * (this.currentSlidesPerView() - 1)) / + this.currentSlidesPerView() + + this.currentGap(); + + return Math.abs(distance) / cellWidth / elapsed > FLICK_VELOCITY; } ngAfterViewInit(): void { diff --git a/src/tedi/components/content/carousel/carousel.component.spec.ts b/src/tedi/components/content/carousel/carousel.component.spec.ts index ed996159d..bd4d5cbef 100644 --- a/src/tedi/components/content/carousel/carousel.component.spec.ts +++ b/src/tedi/components/content/carousel/carousel.component.spec.ts @@ -29,6 +29,63 @@ function dispatchPointerLike( return ev; } +/** + * Simulates a released drag: the track moved from `from` to `to` (in slides), the pointer + * moved `dx` px (negative is towards the next slide) over `ms` milliseconds, and was + * released at `releasedAt` ms (defaults to right after the last move). + */ +function releaseDrag( + component: CarouselContentComponent, + { + from, + to, + dx, + ms, + releasedAt, + }: { from: number; to: number; dx: number; ms: number; releasedAt?: number }, +) { + const drag = component as unknown as Record; + component.dragging = true; + drag["startIndex"] = from; + drag["startX"] = 500; + drag["lastX"] = 500 + dx; + drag["lastTime"] = ms; + drag["samples"] = [ + { x: 500, t: 0 }, + { x: 500 + dx, t: ms }, + ]; + component.trackIndex.set(to); + component.onPointerUp({ timeStamp: releasedAt ?? ms } as PointerEvent); +} + +/** + * Drives the real pointer handlers: presses at the first `[x, timeStamp]` point, moves + * through the rest, then ends with `end` at `endAt` ms (a `pointerup` at `endX`, which + * defaults to the last point). + */ +function gesture( + component: CarouselContentComponent, + points: [number, number][], + end: "pointerup" | "pointercancel" | "lostpointercapture", + endAt: number, + endX = points[points.length - 1][0], +) { + const [[x0, t0], ...moves] = points; + component.onPointerDown({ + clientX: x0, + pointerId: 1, + timeStamp: t0, + } as PointerEvent); + for (const [x, t] of moves) { + component.onPointerMove({ clientX: x, timeStamp: t } as PointerEvent); + } + if (end === "pointerup") { + component.onPointerUp({ clientX: endX, timeStamp: endAt } as PointerEvent); + } else { + component.onPointerCancel(); + } +} + describe("CarouselContentComponent", () => { let fixture: ComponentFixture; let component: CarouselContentComponent; @@ -288,13 +345,214 @@ describe("CarouselContentComponent", () => { }); it("should handle pointer up and stop dragging", () => { - component.dragging = true; component.animate.set(false); - component.trackIndex.set(1.6); - component.onPointerUp(); + releaseDrag(component, { from: 0, to: 1.6, dx: -600, ms: 2000 }); expect(component.dragging).toBe(false); expect(component.animate()).toBe(true); - expect(component.trackIndex()).toBe(Math.round(1.6)); + expect(component.trackIndex()).toBe(2); + }); + + describe("swipe release", () => { + beforeEach(() => { + component.viewportWidth.set(1000); + }); + + it("defaults the threshold to 0.3 of a slide", () => { + expect(component.swipeThreshold()).toBe(0.3); + }); + + it.each([ + ["forwards past the threshold", 1, 1.35, -350, 2], + ["forwards short of the threshold", 1, 1.25, -250, 1], + ["backwards past the threshold", 1, 0.65, 350, 0], + ["backwards short of the threshold", 1, 0.75, 250, 1], + ])("moves on a slow drag %s", (_, from, to, dx, expected) => { + releaseDrag(component, { from, to, dx, ms: 2000 }); + + expect(component.trackIndex()).toBe(expected); + }); + + it("counts each whole slide dragged, plus the remainder past the threshold", () => { + releaseDrag(component, { from: 0, to: 1.2, dx: -1200, ms: 3000 }); + expect(component.trackIndex()).toBe(1); + + releaseDrag(component, { from: 0, to: 1.4, dx: -1400, ms: 3000 }); + expect(component.trackIndex()).toBe(2); + }); + + it("moves one slide on a quick flick, however short", () => { + // 40px in 20ms is ~2 slides per second. + releaseDrag(component, { from: 1, to: 1.04, dx: -40, ms: 20 }); + expect(component.trackIndex()).toBe(2); + + releaseDrag(component, { from: 1, to: 0.96, dx: 40, ms: 20 }); + expect(component.trackIndex()).toBe(0); + }); + + it("does not treat a slow short drag as a flick", () => { + releaseDrag(component, { from: 1, to: 1.04, dx: -40, ms: 400 }); + + expect(component.trackIndex()).toBe(1); + }); + + it("does not treat a drag without a measurable duration as a flick", () => { + releaseDrag(component, { from: 1, to: 1.04, dx: -40, ms: 0 }); + + expect(component.trackIndex()).toBe(1); + }); + + it("does not count a quick move followed by a hold as a flick", () => { + releaseDrag(component, { + from: 1, + to: 1.04, + dx: -40, + ms: 20, + releasedAt: 1000, + }); + + expect(component.trackIndex()).toBe(1); + }); + + it("does not add a step for an exact whole-slide drag at threshold 0", () => { + fixture.componentRef.setInput("swipeThreshold", 0); + fixture.detectChanges(); + // One slide is 1000px plus the 16px gap. + releaseDrag(component, { from: 0, to: 1, dx: -1016, ms: 2000 }); + + expect(component.trackIndex()).toBe(1); + }); + + describe("with the real pointer handlers", () => { + beforeEach(() => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => Array.from({ length: 5 }, () => ({})), + }); + hostElement.setPointerCapture = jest.fn(); + }); + + // 80px in 40ms is ~2 slides per second; a slide is 1016px here. + const quickSwipe: [number, number][] = [ + [500, 0], + [460, 20], + [420, 40], + ]; + + it("moves on a quick swipe released normally", () => { + gesture(component, quickSwipe, "pointerup", 41); + + expect(component.trackIndex()).toBe(1); + }); + + it.each(["pointercancel", "lostpointercapture"] as const)( + "returns to the start when the drag ends with %s", + (end) => { + // The browser took over, e.g. to scroll the page; not a swipe. + gesture(component, quickSwipe, end, 41); + + expect(component.dragging).toBe(false); + expect(component.trackIndex()).toBe(0); + }, + ); + + it("keeps a released swipe when lostpointercapture follows pointerup", () => { + gesture(component, quickSwipe, "pointerup", 41); + component.onPointerCancel(); + + expect(component.trackIndex()).toBe(1); + }); + + it("detects a quick swipe after holding the pointer down", () => { + gesture( + component, + [ + [500, 0], + [500, 600], + [460, 620], + [420, 640], + ], + "pointerup", + 641, + ); + + expect(component.trackIndex()).toBe(1); + }); + + it("counts movement that only the release event reports", () => { + // After a hold, one move is seen; the pointer moves 40px more before release + // without another pointermove. + gesture( + component, + [ + [500, 0], + [460, 620], + ], + "pointerup", + 640, + 420, + ); + + expect(component.trackIndex()).toBe(1); + }); + + it("ignores a quick jitter at the end of a slow drag", () => { + gesture( + component, + [ + [500, 0], + [400, 1000], + [300, 2000], + [297, 2005], + ], + "pointerup", + 2006, + ); + + expect(component.trackIndex()).toBe(0); + }); + + it("does not flick when the quick movement reverses the drag", () => { + gesture( + component, + [ + [500, 0], + [300, 1500], + [340, 1530], + [380, 1560], + ], + "pointerup", + 1561, + ); + + expect(component.trackIndex()).toBe(0); + }); + }); + + it("ignores taps and jitter under 10px, even when quick", () => { + releaseDrag(component, { from: 1, to: 1.009, dx: -9, ms: 5 }); + + expect(component.trackIndex()).toBe(1); + }); + + it("uses a custom threshold", () => { + fixture.componentRef.setInput("swipeThreshold", 0.1); + fixture.detectChanges(); + + releaseDrag(component, { from: 1, to: 1.15, dx: -150, ms: 2000 }); + + expect(component.trackIndex()).toBe(2); + }); + + it.each([ + [2, 1], + [-1, 0], + ["0.5", 0.5], + ])("keeps the threshold between 0 and 1 (%s → %s)", (value, expected) => { + fixture.componentRef.setInput("swipeThreshold", value); + fixture.detectChanges(); + + expect(component.swipeThreshold()).toBe(expected); + }); }); it("should compute trackStyle correctly with viewportWidth set", () => { @@ -1226,17 +1484,27 @@ describe("CarouselContentComponent", () => { expect(component.trackIndex()).toBe(2.5); }); + // The final gap, 2 → 2.5, is half a slide, so 0.3 of it is 0.15. 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(); + [2, 2.1, -50, 2], + [2, 2.2, -100, 2.5], + [2.5, 2.4, 50, 2.5], + [2.5, 2.3, 100, 2], + [0, 0.2, -100, 0], + [0, 0.6, -300, 1], + ])( + "settles a slow drag from %s released at %s on a stop position", + (from, to, dx, expected) => { + releaseDrag(component, { from, to, dx, ms: 2000 }); + + expect(component.trackIndex()).toBe(expected); + }, + ); - expect(component.trackIndex()).toBe(expected); + it("does not step past the end on a long drag", () => { + releaseDrag(component, { from: 2, to: 2.5, dx: -1500, ms: 2000 }); + + expect(component.trackIndex()).toBe(2.5); }); it("stays at the fractional end when wheel scrolling past it", () => { @@ -1272,6 +1540,27 @@ describe("CarouselContentComponent", () => { expect(component.activePosition()).toBe(3); }); + it("reaches and leaves the short final step with a slow swipe", () => { + component.viewportWidth.set(1000); + + // Only 0.1 of a slide separates 2 and the end at 2.1. + releaseDrag(component, { from: 2, to: 2.1, dx: -60, ms: 2000 }); + expect(component.trackIndex()).toBeCloseTo(2.1); + + releaseDrag(component, { from: 2.1, to: 2, dx: 60, ms: 2000 }); + expect(component.trackIndex()).toBe(2); + }); + + it("stays at the end on a quick swipe towards it", () => { + component.viewportWidth.set(1000); + component.trackIndex.set(2.1); + + // The track is already clamped, so only the pointer shows the direction. + releaseDrag(component, { from: 2.1, to: 2.1, dx: -40, ms: 20 }); + + expect(component.trackIndex()).toBeCloseTo(2.1); + }); + it("keeps the whole position active until the end is nearer", () => { component.trackIndex.set(2.04); expect(component.activePosition()).toBe(2); diff --git a/src/tedi/components/content/carousel/carousel.stories.ts b/src/tedi/components/content/carousel/carousel.stories.ts index f048a5e77..33b811154 100644 --- a/src/tedi/components/content/carousel/carousel.stories.ts +++ b/src/tedi/components/content/carousel/carousel.stories.ts @@ -96,6 +96,23 @@ export default { defaultValue: { summary: "400" }, }, }, + swipeThreshold: { + description: + "How far a drag must go to move on, as a fraction (0–1) of the distance to the next position, which is one slide except for a shorter final step when not looping. Each position fully dragged past counts; a quick flick moves one position.", + control: { + type: "number", + min: 0, + max: 1, + step: 0.05, + }, + table: { + category: "Carousel Content", + type: { + summary: "number", + }, + defaultValue: { summary: "0.3" }, + }, + }, 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.", @@ -168,6 +185,7 @@ type CarouselType = CarouselComponent & { gap: BreakpointInput; fade: boolean; transitionMs: number; + swipeThreshold: number; loop: boolean; withArrows: boolean; variant: CarouselIndicatorsVariant; @@ -179,6 +197,7 @@ export const Default: StoryObj = { gap: { xs: 16 }, fade: false, transitionMs: 400, + swipeThreshold: 0.3, withArrows: false, variant: "dots", }, @@ -193,7 +212,7 @@ export const Default: StoryObj = { - + @for (i of [0, 1, 2, 3, 4]; track $index) {