From 8483973894bcc97fa06a50049027e5c9bdbeffc1 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 17 Oct 2026 13:14:45 -0400 Subject: [PATCH] Support shift-scroll zoom from horizontal wheel deltas --- .changeset/shift-scroll-zoom-delta-x.md | 5 + .../TransformContext.svelte.test.ts | 185 ++++++++++++++++++ ...l-that-holding-shift-turned-sideways-1.png | Bin 0 -> 2082 bytes ...l-that-holding-shift-turned-sideways-2.png | Bin 0 -> 2082 bytes ...l-that-holding-shift-turned-sideways-1.png | Bin 0 -> 2082 bytes ...l-that-holding-shift-turned-sideways-2.png | Bin 0 -> 2082 bytes ...a-shift-turned-scroll-in-domain-mode-1.png | Bin 0 -> 4118 bytes ...a-shift-turned-scroll-in-domain-mode-2.png | Bin 0 -> 4118 bytes .../src/lib/states/transform.svelte.ts | 14 +- 9 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 .changeset/shift-scroll-zoom-delta-x.md create mode 100644 packages/layerchart/src/lib/components/__screenshots__/TransformContext.svelte.test.ts/TransformContext-scroll-wheel-should-honour--deltaMode--on-a-scroll-that-holding-shift-turned-sideways-1.png create mode 100644 packages/layerchart/src/lib/components/__screenshots__/TransformContext.svelte.test.ts/TransformContext-scroll-wheel-should-honour--deltaMode--on-a-scroll-that-holding-shift-turned-sideways-2.png create mode 100644 packages/layerchart/src/lib/components/__screenshots__/TransformContext.svelte.test.ts/TransformContext-scroll-wheel-should-zoom-on-a-scroll-that-holding-shift-turned-sideways-1.png create mode 100644 packages/layerchart/src/lib/components/__screenshots__/TransformContext.svelte.test.ts/TransformContext-scroll-wheel-should-zoom-on-a-scroll-that-holding-shift-turned-sideways-2.png create mode 100644 packages/layerchart/src/lib/components/__screenshots__/TransformContext.svelte.test.ts/TransformContext-scroll-wheel-should-zoom-rather-than-also-pan-on-a-shift-turned-scroll-in-domain-mode-1.png create mode 100644 packages/layerchart/src/lib/components/__screenshots__/TransformContext.svelte.test.ts/TransformContext-scroll-wheel-should-zoom-rather-than-also-pan-on-a-shift-turned-scroll-in-domain-mode-2.png diff --git a/.changeset/shift-scroll-zoom-delta-x.md b/.changeset/shift-scroll-zoom-delta-x.md new file mode 100644 index 000000000..69e70bdc3 --- /dev/null +++ b/.changeset/shift-scroll-zoom-delta-x.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(TransformContext): Support `shift` + scroll zoom when the browser reports it as horizontal (`deltaX`) diff --git a/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts b/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts index 54219a650..8c3a56bf1 100644 --- a/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts +++ b/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts @@ -625,4 +625,189 @@ describe('TransformContext', () => { expect(onclick).not.toHaveBeenCalled(); }); }); + + describe('scroll wheel', () => { + /** Render the harness and return the transform state along with a wheel event helper */ + async function setup(chartProps: Record) { + let chartContext: any; + + render(TransformTestHarness, { + chartProps: { height: 300, ...chartProps }, + oncontext: (ctx: any) => { + chartContext = ctx; + }, + }); + + await vi.waitFor(() => expect(chartContext).toBeDefined()); + + // TransformContext is lazy-loaded, so wait for it to render + const element = await vi.waitFor(() => { + const el = document.querySelector('.lc-transform-context'); + expect(el).not.toBeNull(); + return el!; + }); + + const rect = element.getBoundingClientRect(); + + /** Returns the event, so a test can ask whether the page would have scrolled instead */ + function wheel(init: WheelEventInit) { + const event = new WheelEvent('wheel', { + clientX: rect.left + 100, + clientY: rect.top + 100, + bubbles: true, + cancelable: true, + ...init, + }); + element.dispatchEvent(event); + return event; + } + + return { + get transform() { + return chartContext.transform; + }, + wheel, + }; + } + + /** What scrolling up 100 pixels zooms to: 2 ^ (100 * 0.002), d3-zoom's wheel delta */ + const ZOOM_IN = Math.pow(2, 0.2); + + const domainChart = { + data: [ + { date: 0, value: 1 }, + { date: 10, value: 2 }, + ], + x: 'date', + y: 'value', + }; + + it('should zoom on a vertical scroll', async () => { + const { transform, wheel } = await setup({ + transform: { mode: 'canvas', scrollMode: 'scale' }, + }); + + const event = wheel({ deltaY: -100 }); + + expect(transform.scale).toBeCloseTo(ZOOM_IN, 5); + expect(event.defaultPrevented).toBe(true); + }); + + it('should leave the page to scroll when the activation key is not held', async () => { + const { transform, wheel } = await setup({ + transform: { mode: 'canvas', scrollMode: 'scale', scrollActivationKey: 'shift' }, + }); + + const event = wheel({ deltaY: -100 }); + + expect(transform.scale).toBe(1); + expect(event.defaultPrevented).toBe(false); + }); + + it('should zoom on a vertical scroll with shift held as the activation key', async () => { + // A trackpad: holding shift does not turn its vertical swipe sideways + const { transform, wheel } = await setup({ + transform: { mode: 'canvas', scrollMode: 'scale', scrollActivationKey: 'shift' }, + }); + + wheel({ deltaY: -100, shiftKey: true }); + + expect(transform.scale).toBeCloseTo(ZOOM_IN, 5); + }); + + it('should zoom on a scroll that holding shift turned sideways', async () => { + // A mouse wheel on macOS, or any wheel in Firefox: shift moves the scroll to `deltaX` + const { transform, wheel } = await setup({ + transform: { mode: 'canvas', scrollMode: 'scale', scrollActivationKey: 'shift' }, + }); + + const event = wheel({ deltaX: -100, deltaY: 0, shiftKey: true }); + + expect(transform.scale).toBeCloseTo(ZOOM_IN, 5); + expect(event.defaultPrevented).toBe(true); + + // And back out again, the same way + wheel({ deltaX: 100, deltaY: 0, shiftKey: true }); + + expect(transform.scale).toBeCloseTo(1, 5); + }); + + it('should honour `deltaMode` on a scroll that holding shift turned sideways', async () => { + const { transform, wheel } = await setup({ + transform: { mode: 'canvas', scrollMode: 'scale', scrollActivationKey: 'shift' }, + }); + + // Three lines, as Firefox reports a mouse wheel notch: 2 ^ (3 * 0.05) + wheel({ deltaX: -3, deltaY: 0, deltaMode: WheelEvent.DOM_DELTA_LINE, shiftKey: true }); + + expect(transform.scale).toBeCloseTo(Math.pow(2, 0.15), 5); + }); + + it('should not zoom on a sideways scroll without an activation key', async () => { + const { transform, wheel } = await setup({ + transform: { mode: 'canvas', scrollMode: 'scale' }, + }); + + wheel({ deltaX: -100, deltaY: 0 }); + wheel({ deltaX: -100, deltaY: 0, shiftKey: true }); + + expect(transform.scale).toBe(1); + }); + + it('should not read a sideways scroll as a zoom for activation keys other than shift', async () => { + const { transform, wheel } = await setup({ + transform: { mode: 'canvas', scrollMode: 'scale', scrollActivationKey: 'alt' }, + }); + + wheel({ deltaX: -100, deltaY: 0, altKey: true, shiftKey: true }); + + expect(transform.scale).toBe(1); + }); + + it('should still pan on a horizontal trackpad swipe in domain mode', async () => { + const { transform, wheel } = await setup({ + ...domainChart, + transform: { mode: 'domain', scrollMode: 'scale' }, + }); + + wheel({ deltaX: -100, deltaY: 0 }); + + expect(transform.scale).toBe(1); + expect(transform.translate.x).not.toBe(0); + }); + + it('should zoom rather than also pan on a shift-turned scroll in domain mode', async () => { + const { transform, wheel } = await setup({ + ...domainChart, + transform: { mode: 'domain', scrollMode: 'scale', scrollActivationKey: 'shift' }, + }); + + // Where an upright scroll leaves the view... + wheel({ deltaY: -100, shiftKey: true }); + const upright = { scale: transform.scale, translate: { ...transform.translate } }; + transform.reset({ instant: true }); + await vi.waitFor(() => expect(transform.scale).toBe(1)); + + // ...is where the same scroll, turned sideways by shift, leaves it too + wheel({ deltaX: -100, deltaY: 0, shiftKey: true }); + + expect(upright.scale).toBeCloseTo(ZOOM_IN, 5); + expect(transform.scale).toBeCloseTo(upright.scale, 5); + expect(transform.translate.x).toBeCloseTo(upright.translate.x, 5); + expect(transform.translate.y).toBeCloseTo(upright.translate.y, 5); + }); + + it('should leave a sideways scroll alone in translate mode', async () => { + // Panning reads both axes already, and a sideways pan with shift held is a real gesture + const { transform, wheel } = await setup({ + transform: { mode: 'canvas', scrollMode: 'translate', scrollActivationKey: 'shift' }, + }); + + wheel({ deltaX: -100, deltaY: 0, shiftKey: true }); + + expect(transform.scale).toBe(1); + expect(transform.translate.x).not.toBe(0); + expect(transform.translate.y).toBe(0); + }); + }); }); diff --git a/packages/layerchart/src/lib/components/__screenshots__/TransformContext.svelte.test.ts/TransformContext-scroll-wheel-should-honour--deltaMode--on-a-scroll-that-holding-shift-turned-sideways-1.png b/packages/layerchart/src/lib/components/__screenshots__/TransformContext.svelte.test.ts/TransformContext-scroll-wheel-should-honour--deltaMode--on-a-scroll-that-holding-shift-turned-sideways-1.png new file mode 100644 index 0000000000000000000000000000000000000000..47767d2f3db3e57c7b5cb7d5e6f5d4e0ed04e8ff GIT binary patch literal 2082 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