From f429cf8549aced18642b4bf8ef3033e792a0bae1 Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 18:16:33 +0200 Subject: [PATCH 01/16] feat: added support for drag-to-scroll for non-touch pointing devices --- src/components/wfc-forecast-chart.ts | 19 +- src/components/wfc-forecast-simple.ts | 9 + src/controllers/drag-scroll-controller.ts | 218 ++++++++++++++++++++++ src/weather-forecast-card.css | 10 + 4 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 src/controllers/drag-scroll-controller.ts diff --git a/src/components/wfc-forecast-chart.ts b/src/components/wfc-forecast-chart.ts index 819d3a9..5bd09ad 100644 --- a/src/components/wfc-forecast-chart.ts +++ b/src/components/wfc-forecast-chart.ts @@ -1,5 +1,11 @@ import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit"; import { customElement, property, query } from "lit/decorators.js"; +import { DragScrollController } from "../controllers/drag-scroll-controller"; +import { formatDay } from "../helpers"; +import { styleMap } from "lit/directives/style-map.js"; +import ChartDataLabels from "chartjs-plugin-datalabels"; +import { getRelativePosition } from "chart.js/helpers"; +import { actionHandler } from "../hass"; import { ExtendedHomeAssistant, ForecastActionDetails, @@ -10,11 +16,6 @@ import { fireEvent, formatNumber, } from "custom-card-helpers"; -import { formatDay } from "../helpers"; -import { styleMap } from "lit/directives/style-map.js"; -import ChartDataLabels from "chartjs-plugin-datalabels"; -import { getRelativePosition } from "chart.js/helpers"; -import { actionHandler } from "../hass"; import { ForecastAttribute, ForecastType, @@ -60,6 +61,10 @@ export class WfcForecastChart extends LitElement { private _lastChartEvent: PointerEvent | null = null; private _chart: Chart | null = null; + private _scrollController = new DragScrollController(this, { + selector: ".wfc-scroll-container", + childSelector: ".wfc-forecast-slot", + }); protected createRenderRoot() { return this; @@ -449,6 +454,10 @@ export class WfcForecastChart extends LitElement { } private _onForecastAction = (event: ActionHandlerEvent): void => { + if (this._scrollController.isScrolling()) { + return; + } + if (!this._chart || !this._lastChartEvent) { return; } diff --git a/src/components/wfc-forecast-simple.ts b/src/components/wfc-forecast-simple.ts index 909d82e..93fbbce 100644 --- a/src/components/wfc-forecast-simple.ts +++ b/src/components/wfc-forecast-simple.ts @@ -2,6 +2,7 @@ import { html, LitElement, nothing, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ActionHandlerEvent, fireEvent } from "custom-card-helpers"; import { actionHandler } from "../hass"; +import { DragScrollController } from "../controllers/drag-scroll-controller"; import { ExtendedHomeAssistant, ForecastActionDetails, @@ -29,6 +30,10 @@ export class WfcForecastSimple extends LitElement { @property({ attribute: false }) config!: WeatherForecastCardConfig; private _selectedForecastIndex: number | null = null; + private _scrollController = new DragScrollController(this, { + selector: ".wfc-scroll-container", + childSelector: ".wfc-forecast-slot", + }); protected createRenderRoot() { return this; @@ -115,6 +120,10 @@ export class WfcForecastSimple extends LitElement { }; private _onForecastAction = (event: ActionHandlerEvent): void => { + if (this._scrollController.isScrolling()) { + return; + } + if (this._selectedForecastIndex === null) return; event.preventDefault(); diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts new file mode 100644 index 0000000..bf78f11 --- /dev/null +++ b/src/controllers/drag-scroll-controller.ts @@ -0,0 +1,218 @@ +import type { + ReactiveController, + ReactiveControllerHost, + LitElement, +} from "lit"; + +export interface DragScrollControllerConfig { + selector: string; + childSelector?: string; +} + +type DragScrollState = { + startX: number; + startLeft: number; + lastX: number; + velocity: number; + momentumId: number; +}; + +const DRAG_MOVEMENT_THRESHOLD = 3; +const FRICTION_COEFFICIENT = 0.95; +const SNAP_ANIMATION_DURATION_MS = 500; + +export class DragScrollController implements ReactiveController { + private _mouseDown = false; + private _scrolling = false; + private _scrolled = false; + private _host: ReactiveControllerHost & LitElement; + private _selector: string; + private _childSelector?: string; + private _container?: HTMLElement | null; + private _state: DragScrollState = { + startX: 0, + startLeft: 0, + lastX: 0, + velocity: 0, + momentumId: 0, + }; + + constructor( + host: ReactiveControllerHost & LitElement, + config: DragScrollControllerConfig + ) { + this._host = host; + this._selector = config.selector; + this._childSelector = config.childSelector; + host.addController(this); + } + + public hostUpdated() { + if (!this._container) { + this._attach(); + } + } + + public hostDisconnected() { + this._detach(); + } + + public isScrolling(): boolean { + return this._scrolled || this._scrolling; + } + + private _attach() { + this._container = this._host.renderRoot?.querySelector(this._selector); + this._container?.addEventListener("mousedown", this._onMouseDown); + } + + private _detach() { + this._cleanup(); + + if (this._container) { + this._container.classList.remove("is-dragging", "no-snap"); + this._container.removeEventListener("mousedown", this._onMouseDown); + this._container = undefined; + } + } + + private _onMouseDown = (event: MouseEvent) => { + if (!this._container) return; + + this._mouseDown = true; + this._scrolled = false; + this._scrolling = false; + + this._state.startX = event.pageX - this._container.offsetLeft; + this._state.startLeft = this._container.scrollLeft; + this._state.lastX = event.pageX; + this._state.velocity = 0; + + cancelAnimationFrame(this._state.momentumId); + + window.addEventListener("mousemove", this._onMouseMove); + window.addEventListener("mouseup", this._onMouseUp, { once: true }); + + this._host.requestUpdate(); + }; + + private _onMouseMove = (event: MouseEvent) => { + if (!this._mouseDown || !this._container) return; + + const x = event.pageX - this._container.offsetLeft; + const walk = x - this._state.startX; + + // Track velocity for the momentum if user flicks the pointing device + this._state.velocity = event.pageX - this._state.lastX; + this._state.lastX = event.pageX; + + // Avoid scrolling if the user hasn't moved enough yet, i.e. this might be a click + // and should be handled by action-handler-directive instead + if (!this._scrolled && Math.abs(walk) > DRAG_MOVEMENT_THRESHOLD) { + this._scrolled = true; + this._scrolling = true; + + this._container.classList.add("is-dragging", "no-snap"); + this._host.requestUpdate(); + } + + if (this._scrolled) { + this._container.scrollLeft = this._state.startLeft - walk; + } + }; + + private _onMouseUp = () => { + this._cleanup(); + + if (this._container) { + this._container.classList.remove("is-dragging"); + + if (!this._scrolled) { + this._container.classList.remove("no-snap"); + return; + } + + if (Math.abs(this._state.velocity) > 1) { + this._runMomentum(); + } else { + this._finalize(); + } + } + this._host.requestUpdate(); + }; + + private _runMomentum = () => { + if (!this._container || Math.abs(this._state.velocity) < 0.5) { + this._finalize(); + return; + } + + this._container.scrollLeft -= this._state.velocity; + this._state.velocity *= FRICTION_COEFFICIENT; + this._state.momentumId = requestAnimationFrame(this._runMomentum); + }; + + /** + * Finalizes the scrolling by snapping to the nearest item. + * + * If the user flicked the scroll, this method ensures a smooth deceleration + * and snapping to the nearest item in the scroll container using linear interpolation. + */ + private _finalize = () => { + if (!this._container || !this._childSelector) { + this._container?.classList.remove("no-snap"); + this._scrolling = false; + return; + } + + const item = this._container.querySelector( + this._childSelector + ) as HTMLElement; + if (!item) return; + + const itemWidth = item.getBoundingClientRect().width; + const startLeft = this._container.scrollLeft; + const targetLeft = Math.round(startLeft / itemWidth) * itemWidth; + + const duration = SNAP_ANIMATION_DURATION_MS; + const startTime = performance.now(); + + // Easing function: Ease-Out Quad (starts fast, finishes very slow) + const easeOutQuad = (t: number) => t * (2 - t); + + const animateSnap = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easeOutQuad(progress); + + if (this._container) { + this._container.scrollLeft = + startLeft + (targetLeft - startLeft) * easedProgress; + } + + if (progress < 1) { + this._state.momentumId = requestAnimationFrame(animateSnap); + } else { + this._completeFinalize(); + } + }; + + this._state.momentumId = requestAnimationFrame(animateSnap); + }; + + private _completeFinalize = () => { + this._scrolling = false; + this._container?.classList.remove("no-snap"); + + setTimeout(() => { + this._scrolled = false; + this._host.requestUpdate(); + }, 50); + }; + + private _cleanup() { + this._mouseDown = false; + window.removeEventListener("mousemove", this._onMouseMove); + cancelAnimationFrame(this._state.momentumId); + } +} diff --git a/src/weather-forecast-card.css b/src/weather-forecast-card.css index b1db7a5..29fb4a8 100644 --- a/src/weather-forecast-card.css +++ b/src/weather-forecast-card.css @@ -219,6 +219,7 @@ ha-card { min-width: 0; max-width: 100%; scroll-snap-type: x mandatory; + scroll-behavior: smooth; overflow-x: auto; overflow-y: clip; scrollbar-width: none; @@ -231,6 +232,15 @@ ha-card { -webkit-overflow-scrolling: touch; } +.wfc-scroll-container.is-dragging { + cursor: grabbing !important; +} + +.wfc-scroll-container.no-snap { + scroll-snap-type: none !important; + scroll-behavior: auto !important; +} + .wfc-scroll-container::-webkit-scrollbar { display: none; } From a5e200d8f06676b417ba589d0c48aa9dc0e38a52 Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 18:22:06 +0200 Subject: [PATCH 02/16] test: added tests for drag-to-scroll --- test/weather-forecast-chart.test.ts | 45 +++++++++++++++++++++++++++ test/weather-forecast-simple.test.ts | 46 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/test/weather-forecast-chart.test.ts b/test/weather-forecast-chart.test.ts index 3559fd1..fa841c8 100644 --- a/test/weather-forecast-chart.test.ts +++ b/test/weather-forecast-chart.test.ts @@ -244,4 +244,49 @@ describe("weather-forecast-card chart", () => { // @ts-expect-error: deep access expect(options.scales.x.grid.color).toBe(testColors.grid); }); + + it("should support drag-to-scroll when dragging", async () => { + const chartComponent = card.shadowRoot!.querySelector("wfc-forecast-chart"); + expect(chartComponent).not.toBeNull(); + + const scrollContainer = chartComponent!.querySelector( + ".wfc-scroll-container" + ) as HTMLElement; + expect(scrollContainer).not.toBeNull(); + + expect(scrollContainer.classList.contains("is-dragging")).toBe(false); + + const mouseDownEvent = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + clientX: 100, + }); + + Object.defineProperty(mouseDownEvent, "pageX", { value: 100 }); + + scrollContainer.dispatchEvent(mouseDownEvent); + + const mouseMoveEvent = new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + clientX: 50, + }); + + Object.defineProperty(mouseMoveEvent, "pageX", { value: 50 }); + + window.dispatchEvent(mouseMoveEvent); + + expect(scrollContainer.classList.contains("is-dragging")).toBe(true); + expect(scrollContainer.classList.contains("no-snap")).toBe(true); + + const mouseUpEvent = new MouseEvent("mouseup", { + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(mouseUpEvent); + + expect(scrollContainer.classList.contains("is-dragging")).toBe(false); + + expect(scrollContainer.scrollLeft).toBeGreaterThan(0); + }); }); diff --git a/test/weather-forecast-simple.test.ts b/test/weather-forecast-simple.test.ts index 9e9c3c3..5786082 100644 --- a/test/weather-forecast-simple.test.ts +++ b/test/weather-forecast-simple.test.ts @@ -155,4 +155,50 @@ describe("weather-forecast-card simple", () => { ).toBeNull(); }); }); + + it("should support drag-to-scroll when dragging", async () => { + const simpleComponent = card.shadowRoot!.querySelector( + "wfc-forecast-simple" + ); + expect(simpleComponent).not.toBeNull(); + + const scrollContainer = simpleComponent!.querySelector( + ".wfc-scroll-container" + ) as HTMLElement; + expect(scrollContainer).not.toBeNull(); + + expect(scrollContainer.classList.contains("is-dragging")).toBe(false); + + const mouseDownEvent = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + clientX: 100, + }); + + Object.defineProperty(mouseDownEvent, "pageX", { value: 100 }); + scrollContainer.dispatchEvent(mouseDownEvent); + + const mouseMoveEvent = new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + clientX: 50, + }); + + Object.defineProperty(mouseMoveEvent, "pageX", { value: 50 }); + window.dispatchEvent(mouseMoveEvent); + + expect(scrollContainer.classList.contains("is-dragging")).toBe(true); + expect(scrollContainer.classList.contains("no-snap")).toBe(true); + + const mouseUpEvent = new MouseEvent("mouseup", { + bubbles: true, + cancelable: true, + }); + + window.dispatchEvent(mouseUpEvent); + + expect(scrollContainer.classList.contains("is-dragging")).toBe(false); + + expect(scrollContainer.scrollLeft).toBeGreaterThan(0); + }); }); From dbcd155ef523fae1037b5fb37b08bc58d780fd8e Mon Sep 17 00:00:00 2001 From: Tero Roininen Date: Tue, 30 Dec 2025 18:27:30 +0200 Subject: [PATCH 03/16] fix: review fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tero Roininen --- src/controllers/drag-scroll-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index bf78f11..9c573f2 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -156,7 +156,7 @@ export class DragScrollController implements ReactiveController { * Finalizes the scrolling by snapping to the nearest item. * * If the user flicked the scroll, this method ensures a smooth deceleration - * and snapping to the nearest item in the scroll container using linear interpolation. + * and snapping to the nearest item in the scroll container using an ease-out quadratic easing function. */ private _finalize = () => { if (!this._container || !this._childSelector) { From 7f0a0107d32b1cc8c693738a9ffcbf926b0b30a8 Mon Sep 17 00:00:00 2001 From: Tero Roininen Date: Tue, 30 Dec 2025 18:28:38 +0200 Subject: [PATCH 04/16] test: review fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tero Roininen --- test/weather-forecast-simple.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/weather-forecast-simple.test.ts b/test/weather-forecast-simple.test.ts index 5786082..b99c207 100644 --- a/test/weather-forecast-simple.test.ts +++ b/test/weather-forecast-simple.test.ts @@ -197,6 +197,7 @@ describe("weather-forecast-card simple", () => { window.dispatchEvent(mouseUpEvent); + await new Promise((resolve) => setTimeout(resolve, 150)); expect(scrollContainer.classList.contains("is-dragging")).toBe(false); expect(scrollContainer.scrollLeft).toBeGreaterThan(0); From 340b0909f716f3cecdc4842589f58cf19f35decb Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 18:36:14 +0200 Subject: [PATCH 05/16] test: review fixes for tests --- test/weather-forecast-chart.test.ts | 25 ++++++++++++++++++++++--- test/weather-forecast-simple.test.ts | 22 +++++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/test/weather-forecast-chart.test.ts b/test/weather-forecast-chart.test.ts index fa841c8..4ca4bae 100644 --- a/test/weather-forecast-chart.test.ts +++ b/test/weather-forecast-chart.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fixture } from "@open-wc/testing"; import { html } from "lit"; import { MockHass } from "./mocks/hass"; @@ -259,10 +259,10 @@ describe("weather-forecast-card chart", () => { const mouseDownEvent = new MouseEvent("mousedown", { bubbles: true, cancelable: true, - clientX: 100, + clientX: 250, }); - Object.defineProperty(mouseDownEvent, "pageX", { value: 100 }); + Object.defineProperty(mouseDownEvent, "pageX", { value: 250 }); scrollContainer.dispatchEvent(mouseDownEvent); @@ -283,8 +283,27 @@ describe("weather-forecast-card chart", () => { bubbles: true, cancelable: true, }); + + // Mock dimensions to ensure snapping logic sees a width + const scrollSlot = scrollContainer.querySelector(".wfc-forecast-slot"); + if (scrollSlot) { + vi.spyOn(scrollSlot, "getBoundingClientRect").mockReturnValue({ + width: 100, + height: 100, + top: 0, + left: 0, + right: 100, + bottom: 100, + x: 0, + y: 0, + toJSON: () => {}, + }); + } + window.dispatchEvent(mouseUpEvent); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(scrollContainer.classList.contains("is-dragging")).toBe(false); expect(scrollContainer.scrollLeft).toBeGreaterThan(0); diff --git a/test/weather-forecast-simple.test.ts b/test/weather-forecast-simple.test.ts index b99c207..bf666af 100644 --- a/test/weather-forecast-simple.test.ts +++ b/test/weather-forecast-simple.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fixture } from "@open-wc/testing"; import { html } from "lit"; import { MockHass } from "./mocks/hass"; @@ -172,10 +172,10 @@ describe("weather-forecast-card simple", () => { const mouseDownEvent = new MouseEvent("mousedown", { bubbles: true, cancelable: true, - clientX: 100, + clientX: 250, }); - Object.defineProperty(mouseDownEvent, "pageX", { value: 100 }); + Object.defineProperty(mouseDownEvent, "pageX", { value: 250 }); scrollContainer.dispatchEvent(mouseDownEvent); const mouseMoveEvent = new MouseEvent("mousemove", { @@ -195,6 +195,22 @@ describe("weather-forecast-card simple", () => { cancelable: true, }); + // Mock dimensions to ensure snapping logic sees a width + const scrollSlot = scrollContainer.querySelector(".wfc-forecast-slot"); + if (scrollSlot) { + vi.spyOn(scrollSlot, "getBoundingClientRect").mockReturnValue({ + width: 100, + height: 100, + top: 0, + left: 0, + right: 100, + bottom: 100, + x: 0, + y: 0, + toJSON: () => {}, + }); + } + window.dispatchEvent(mouseUpEvent); await new Promise((resolve) => setTimeout(resolve, 150)); From 44f1a91b9da9d8bf487614fe8695aa83ff265ed5 Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 18:39:29 +0200 Subject: [PATCH 06/16] fix: finalize scrolling properly in exception cases --- src/controllers/drag-scroll-controller.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 9c573f2..055ac88 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -160,15 +160,18 @@ export class DragScrollController implements ReactiveController { */ private _finalize = () => { if (!this._container || !this._childSelector) { - this._container?.classList.remove("no-snap"); - this._scrolling = false; + this._completeFinalize(); return; } const item = this._container.querySelector( this._childSelector ) as HTMLElement; - if (!item) return; + + if (!item) { + this._completeFinalize(); + return; + } const itemWidth = item.getBoundingClientRect().width; const startLeft = this._container.scrollLeft; From b194c7360d79823b0f8f7af29b42f317182e334d Mon Sep 17 00:00:00 2001 From: Tero Roininen Date: Tue, 30 Dec 2025 18:45:52 +0200 Subject: [PATCH 07/16] fix: review fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tero Roininen --- src/controllers/drag-scroll-controller.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 055ac88..09ff080 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -21,6 +21,27 @@ const DRAG_MOVEMENT_THRESHOLD = 3; const FRICTION_COEFFICIENT = 0.95; const SNAP_ANIMATION_DURATION_MS = 500; +/** + * Reactive controller that adds drag-to-scroll behavior to a LitElement host. + * + * The controller locates a scrollable container within the host's rendered + * DOM using the provided `selector`, and optionally uses `childSelector` + * to snap the scroll position to individual child elements when the drag + * interaction ends. + * + * Lifecycle integration: + * - Registers itself with the host via `host.addController(this)` in the + * constructor, so it participates in the host's reactive lifecycle. + * - Uses the `hostUpdated` hook to attach event listeners once the host's + * template has been rendered and the target container is available. + * + * Usage: + * - Instantiate in a LitElement and pass `this` as the host along with a + * configuration object: + * `new DragScrollController(this, { selector: '.scroll-container', childSelector: '.item' });` + * - The controller manages mouse events, drag state, momentum scrolling, + * and optional snapping without requiring additional logic in the host. + */ export class DragScrollController implements ReactiveController { private _mouseDown = false; private _scrolling = false; From ef46220d87206113ed4d8830e6131bea79bd731c Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 18:51:03 +0200 Subject: [PATCH 08/16] fix: make sure mouseup listener gets removed --- src/controllers/drag-scroll-controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 09ff080..e781309 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -237,6 +237,7 @@ export class DragScrollController implements ReactiveController { private _cleanup() { this._mouseDown = false; window.removeEventListener("mousemove", this._onMouseMove); + window.removeEventListener("mouseup", this._onMouseUp); cancelAnimationFrame(this._state.momentumId); } } From 932c7b7563eac52bba921e7dafaaa41c2642e438 Mon Sep 17 00:00:00 2001 From: Tero Roininen Date: Tue, 30 Dec 2025 18:52:56 +0200 Subject: [PATCH 09/16] fix: review fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tero Roininen --- src/controllers/drag-scroll-controller.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index e781309..10f918a 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -78,6 +78,14 @@ export class DragScrollController implements ReactiveController { this._detach(); } + /** + * Indicates whether a drag-based scroll interaction is currently in progress + * or has occurred during the current mouse interaction. + * + * Returns `true` while the container is actively being scrolled due to + * dragging or momentum, or if a drag gesture in the current interaction + * has produced any horizontal scrolling. + */ public isScrolling(): boolean { return this._scrolled || this._scrolling; } From 495b9edbd1eb08202b9cf601e2ad7a85eff77ea8 Mon Sep 17 00:00:00 2001 From: Tero Roininen Date: Tue, 30 Dec 2025 18:54:01 +0200 Subject: [PATCH 10/16] fix: review fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tero Roininen --- src/controllers/drag-scroll-controller.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 10f918a..5519558 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -203,6 +203,12 @@ export class DragScrollController implements ReactiveController { } const itemWidth = item.getBoundingClientRect().width; + + // If the item has no width (e.g., not rendered yet), skip snapping to avoid NaN scroll values. + if (!itemWidth || !Number.isFinite(itemWidth)) { + this._completeFinalize(); + return; + } const startLeft = this._container.scrollLeft; const targetLeft = Math.round(startLeft / itemWidth) * itemWidth; From 482f86ef790805b68d3d4e767643b4081d8054a6 Mon Sep 17 00:00:00 2001 From: Tero Roininen Date: Tue, 30 Dec 2025 18:54:43 +0200 Subject: [PATCH 11/16] fix: review fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tero Roininen --- src/controllers/drag-scroll-controller.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 5519558..51a72da 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -4,8 +4,28 @@ import type { LitElement, } from "lit"; +/** + * Configuration options for {@link DragScrollController}. + * + * Used to locate the scrollable container within the host element and, + * optionally, the child elements that should be used for snap-to-element + * behavior. + */ export interface DragScrollControllerConfig { + /** + * CSS selector used to find the scrollable container element inside the host. + * The first element matching this selector will be used as the drag/scroll + * container. + */ selector: string; + /** + * Optional CSS selector used to locate child elements within the scroll + * container for snap-to-element behavior. + * + * When provided, the controller will attempt to snap the scroll position to + * the nearest matching child element after a drag/scroll interaction ends. + * If omitted, no snap-to-element behavior is applied. + */ childSelector?: string; } From 8c4d418d892f5d55571fafd6483398eba16dcb63 Mon Sep 17 00:00:00 2001 From: Tero Roininen Date: Tue, 30 Dec 2025 18:55:41 +0200 Subject: [PATCH 12/16] fix: review fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tero Roininen --- src/controllers/drag-scroll-controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 51a72da..e11f8d5 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -191,12 +191,13 @@ export class DragScrollController implements ReactiveController { }; private _runMomentum = () => { - if (!this._container || Math.abs(this._state.velocity) < 0.5) { + const container = this._container; + if (!container || Math.abs(this._state.velocity) < 0.5) { this._finalize(); return; } - this._container.scrollLeft -= this._state.velocity; + container.scrollLeft -= this._state.velocity; this._state.velocity *= FRICTION_COEFFICIENT; this._state.momentumId = requestAnimationFrame(this._runMomentum); }; From 964ab5944dedc06d5dfb2c5240a1e5d5a68c8df7 Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 19:00:57 +0200 Subject: [PATCH 13/16] fix: review fixes --- src/controllers/drag-scroll-controller.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index e11f8d5..183d0b9 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -209,14 +209,13 @@ export class DragScrollController implements ReactiveController { * and snapping to the nearest item in the scroll container using an ease-out quadratic easing function. */ private _finalize = () => { - if (!this._container || !this._childSelector) { + const container = this._container; + if (!container || !this._childSelector) { this._completeFinalize(); return; } - const item = this._container.querySelector( - this._childSelector - ) as HTMLElement; + const item = container.querySelector(this._childSelector) as HTMLElement; if (!item) { this._completeFinalize(); @@ -230,7 +229,7 @@ export class DragScrollController implements ReactiveController { this._completeFinalize(); return; } - const startLeft = this._container.scrollLeft; + const startLeft = container.scrollLeft; const targetLeft = Math.round(startLeft / itemWidth) * itemWidth; const duration = SNAP_ANIMATION_DURATION_MS; @@ -240,14 +239,18 @@ export class DragScrollController implements ReactiveController { const easeOutQuad = (t: number) => t * (2 - t); const animateSnap = (currentTime: number) => { + const container = this._container; + if (!container) { + this._completeFinalize(); + return; + } + const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easedProgress = easeOutQuad(progress); - if (this._container) { - this._container.scrollLeft = - startLeft + (targetLeft - startLeft) * easedProgress; - } + container.scrollLeft = + startLeft + (targetLeft - startLeft) * easedProgress; if (progress < 1) { this._state.momentumId = requestAnimationFrame(animateSnap); From f3c591ed5e40b56bfc8d73bc16f5daac720e7bfb Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 19:04:23 +0200 Subject: [PATCH 14/16] fix: review fix --- src/controllers/drag-scroll-controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 183d0b9..00f5aaa 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -70,6 +70,7 @@ export class DragScrollController implements ReactiveController { private _selector: string; private _childSelector?: string; private _container?: HTMLElement | null; + private _finalizeId?: number; private _state: DragScrollState = { startX: 0, startLeft: 0, @@ -266,7 +267,8 @@ export class DragScrollController implements ReactiveController { this._scrolling = false; this._container?.classList.remove("no-snap"); - setTimeout(() => { + clearTimeout(this._finalizeId); + this._finalizeId = window.setTimeout(() => { this._scrolled = false; this._host.requestUpdate(); }, 50); @@ -277,5 +279,6 @@ export class DragScrollController implements ReactiveController { window.removeEventListener("mousemove", this._onMouseMove); window.removeEventListener("mouseup", this._onMouseUp); cancelAnimationFrame(this._state.momentumId); + clearTimeout(this._finalizeId); } } From 055ac6b9b432664b21836541cfa9b467daec0312 Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 19:06:56 +0200 Subject: [PATCH 15/16] fix: review fix --- src/controllers/drag-scroll-controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 00f5aaa..7044330 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -163,7 +163,6 @@ export class DragScrollController implements ReactiveController { this._scrolling = true; this._container.classList.add("is-dragging", "no-snap"); - this._host.requestUpdate(); } if (this._scrolled) { From 5535ab05400763a52d0c197299c23fc8efbe2397 Mon Sep 17 00:00:00 2001 From: troinine Date: Tue, 30 Dec 2025 19:18:20 +0200 Subject: [PATCH 16/16] fix: review fix for better performance --- src/controllers/drag-scroll-controller.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/controllers/drag-scroll-controller.ts b/src/controllers/drag-scroll-controller.ts index 7044330..4c8efbf 100644 --- a/src/controllers/drag-scroll-controller.ts +++ b/src/controllers/drag-scroll-controller.ts @@ -166,7 +166,15 @@ export class DragScrollController implements ReactiveController { } if (this._scrolled) { - this._container.scrollLeft = this._state.startLeft - walk; + const container = this._container; + const targetScrollLeft = this._state.startLeft - walk; + + requestAnimationFrame(() => { + if (!container) { + return; + } + container.scrollLeft = targetScrollLeft; + }); } };