diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts
index db1c58bae7..ef2a00a7e9 100644
--- a/projects/core/src/index.test.lighthouse.ts
+++ b/projects/core/src/index.test.lighthouse.ts
@@ -18,7 +18,7 @@ describe('lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(106.14);
+ expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(107);
// if sudden drop in size, check vite bundle config and bundle demo to ensure side effects are properly preserved
expect(report.payload.javascript.requests['index.js'].kb).toBeGreaterThan(100);
@@ -106,6 +106,6 @@ describe('lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.requests[Object.keys(report.payload.javascript.requests)[0]].kb).toBeLessThan(88);
+ expect(report.payload.javascript.requests[Object.keys(report.payload.javascript.requests)[0]].kb).toBeLessThan(89);
});
});
diff --git a/projects/core/src/internal/controllers/gesture.controller.examples.ts b/projects/core/src/internal/controllers/gesture.controller.examples.ts
new file mode 100644
index 0000000000..1dc540b293
--- /dev/null
+++ b/projects/core/src/internal/controllers/gesture.controller.examples.ts
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { css, html, LitElement } from 'lit';
+import { GestureController } from '@nvidia-elements/core/internal';
+
+export default {
+ title: 'Internal/Controllers'
+};
+
+class GestureDemoElement extends LitElement {
+ readonly #gestureController = new GestureController(this, {
+ getCapabilities: () => ({ drag: true, pan: false, pinch: false, wheel: false })
+ });
+
+ static styles = css`
+ :host {
+ cursor: grab;
+ display: grid;
+ height: 64px;
+ place-items: center;
+ width: 64px;
+ }
+
+ :host([data-active]) {
+ cursor: grabbing;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.#gestureController.target = this;
+ }
+
+ render() {
+ return html`drag`;
+ }
+}
+
+if (!customElements.get('gesture-controller-demo-element')) {
+ customElements.define('gesture-controller-demo-element', GestureDemoElement);
+}
+
+/**
+ * @summary Drag recognition delivered through gesture and unmatched pointer events. Use event details to move content and represent active pointer state.
+ * @tags test-case
+ */
+export const Drag = {
+ render: () => html`
+
+
+`
+};
diff --git a/projects/core/src/internal/controllers/gesture.controller.test.ts b/projects/core/src/internal/controllers/gesture.controller.test.ts
new file mode 100644
index 0000000000..783ae6e09c
--- /dev/null
+++ b/projects/core/src/internal/controllers/gesture.controller.test.ts
@@ -0,0 +1,345 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html, type ReactiveController } from 'lit';
+import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
+import { createFixture, removeFixture } from '@internals/testing';
+import {
+ GestureController,
+ type Gesture,
+ type GestureCapabilities,
+ type GestureControllerOptions,
+ type UnhandledPointerInput
+} from '@nvidia-elements/core/internal';
+
+class GestureControllerTestHost extends HTMLElement {
+ readonly #controllers = new Set();
+ readonly updateComplete = Promise.resolve(true);
+
+ addController(controller: ReactiveController): void {
+ this.#controllers.add(controller);
+ }
+
+ removeController(controller: ReactiveController): void {
+ this.#controllers.delete(controller);
+ }
+
+ requestUpdate(): void {}
+
+ connectedCallback(): void {
+ this.#controllers.forEach(controller => controller.hostConnected?.());
+ }
+
+ disconnectedCallback(): void {
+ this.#controllers.forEach(controller => controller.hostDisconnected?.());
+ }
+}
+
+const tag = 'gesture-controller-test-host';
+if (!customElements.get(tag)) customElements.define(tag, GestureControllerTestHost);
+
+describe('GestureController', () => {
+ let capabilities: GestureCapabilities;
+ let controller: GestureController;
+ let fixture: HTMLElement;
+ let gestureEvents: CustomEvent>[];
+ let host: GestureControllerTestHost;
+ let ignored: boolean;
+ let pointerInputEvents: CustomEvent[];
+ let prepare: ReturnType;
+ let target: HTMLElement;
+
+ beforeEach(async () => {
+ fixture = await createFixture(html``);
+ host = document.createElement(tag) as GestureControllerTestHost;
+ target = document.createElement('div');
+ host.append(target);
+ capabilities = { drag: true, pan: true, pinch: true, wheel: true };
+ gestureEvents = [];
+ ignored = false;
+ pointerInputEvents = [];
+ prepare = vi.fn();
+ const options: GestureControllerOptions = {
+ createPinchContext: () => 'pinch-start',
+ getCapabilities: () => capabilities,
+ prepare,
+ shouldIgnore: () => ignored
+ };
+ controller = new GestureController(host, options);
+ controller.target = target;
+ host.addEventListener('nve-gesture', event => gestureEvents.push(event as CustomEvent>));
+ host.addEventListener('nve-pointer-input', event =>
+ pointerInputEvents.push(event as CustomEvent)
+ );
+ vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 200, 100));
+ vi.spyOn(target, 'setPointerCapture').mockImplementation(() => {});
+ fixture.append(host);
+ });
+
+ afterEach(() => {
+ removeFixture(fixture);
+ vi.restoreAllMocks();
+ });
+
+ it('exposes recognition configuration without input callbacks', () => {
+ const options: GestureControllerOptions = {
+ getCapabilities: () => ({ drag: true, pan: false, pinch: false, wheel: false })
+ };
+
+ expectTypeOf<
+ Extract, 'onGesture' | 'onUnhandledPointer'>
+ >().toEqualTypeOf();
+ expect(options.createPinchContext).toBeUndefined();
+ });
+
+ it('recognizes drag without a pinch context factory', () => {
+ const dragHost = document.createElement(tag) as GestureControllerTestHost;
+ const dragTarget = document.createElement('div');
+ const dragEvents: CustomEvent>[] = [];
+ dragHost.append(dragTarget);
+ const dragController = new GestureController(dragHost, {
+ getCapabilities: () => ({ drag: true, pan: false, pinch: false, wheel: false })
+ });
+ dragController.target = dragTarget;
+ dragHost.addEventListener('nve-gesture', event => dragEvents.push(event as CustomEvent>));
+ vi.spyOn(dragTarget, 'setPointerCapture').mockImplementation(() => {});
+ fixture.append(dragHost);
+
+ dragTarget.dispatchEvent(pointerEvent('pointerdown', { clientX: 2, clientY: 3, pointerId: 1 }));
+ const move = pointerEvent('pointermove', { clientX: 7, clientY: 9, pointerId: 1 });
+ dragTarget.dispatchEvent(move);
+
+ expect(dragEvents.map(event => event.detail)).toEqual([{ event: move, kind: 'drag', movementX: 5, movementY: 6 }]);
+ });
+
+ it('disables reported pinch capability without a pinch context factory', () => {
+ const pinchHost = document.createElement(tag) as GestureControllerTestHost;
+ const pinchTarget = document.createElement('div');
+ const gestures = vi.fn();
+ const inputs: UnhandledPointerInput[] = [];
+ pinchHost.append(pinchTarget);
+ const pinchController = new GestureController(pinchHost, {
+ getCapabilities: () => ({ drag: false, pan: false, pinch: true, wheel: false })
+ });
+ pinchController.target = pinchTarget;
+ pinchHost.addEventListener('nve-gesture', gestures);
+ pinchHost.addEventListener('nve-pointer-input', event =>
+ inputs.push((event as CustomEvent).detail)
+ );
+ const setPointerCapture = vi.spyOn(pinchTarget, 'setPointerCapture').mockImplementation(() => {});
+ fixture.append(pinchHost);
+
+ pinchTarget.dispatchEvent(
+ pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1, pointerType: 'touch' })
+ );
+ pinchTarget.dispatchEvent(
+ pointerEvent('pointerdown', { clientX: 20, clientY: 10, pointerId: 2, pointerType: 'touch' })
+ );
+ const move = pointerEvent('pointermove', {
+ clientX: 30,
+ clientY: 10,
+ pointerId: 2,
+ pointerType: 'touch'
+ });
+ pinchTarget.dispatchEvent(move);
+
+ expect(gestures).not.toHaveBeenCalled();
+ expect(inputs.map(input => input.kind)).toEqual(['pointerdown', 'pointerdown', 'pointermove']);
+ expect(setPointerCapture).not.toHaveBeenCalled();
+ });
+
+ it('dispatches synchronous custom events on the host before target listeners', () => {
+ const order: string[] = [];
+ const parentGesture = vi.fn();
+ const parentPointerInput = vi.fn();
+ fixture.addEventListener('nve-gesture', parentGesture);
+ fixture.addEventListener('nve-pointer-input', parentPointerInput);
+ host.addEventListener('nve-pointer-input', () => order.push('pointer-input'));
+ host.addEventListener('nve-gesture', () => order.push('gesture'));
+ target.addEventListener('pointerdown', () => order.push('pointerdown'));
+ target.addEventListener('pointermove', () => order.push('pointermove'));
+
+ const down = pointerEvent('pointerdown', { clientX: 1, clientY: 2, pointerId: 1 });
+ target.dispatchEvent(down);
+ expect(order).toEqual(['pointer-input', 'pointerdown']);
+
+ const move = pointerEvent('pointermove', { clientX: 3, clientY: 5, pointerId: 1 });
+ target.dispatchEvent(move);
+ expect(order).toEqual(['pointer-input', 'pointerdown', 'gesture', 'pointermove']);
+
+ const inputEvent = pointerInputEvents[0];
+ const gestureEvent = gestureEvents[0];
+ expect(inputEvent?.target).toBe(host);
+ expect(inputEvent?.detail).toEqual({ event: down, kind: 'pointerdown' });
+ expect(gestureEvent?.target).toBe(host);
+ expect(gestureEvent?.detail).toEqual({ event: move, kind: 'drag', movementX: 2, movementY: 3 });
+ for (const event of [inputEvent, gestureEvent]) {
+ expect(event).toMatchObject({ bubbles: false, cancelable: false, composed: false });
+ }
+ expect(parentGesture).not.toHaveBeenCalled();
+ expect(parentPointerInput).not.toHaveBeenCalled();
+ });
+
+ it('tracks primary drags while routing pointer boundaries and stationary movement', () => {
+ target.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }));
+ const move = pointerEvent('pointermove', { clientX: 16, clientY: 24, pointerId: 1 });
+ const stationary = pointerEvent('pointermove', { clientX: 16, clientY: 24, pointerId: 1 });
+
+ expect(target.dispatchEvent(move)).toBe(false);
+ expect(target.dispatchEvent(stationary)).toBe(true);
+ target.dispatchEvent(pointerEvent('pointerup', { clientX: 16, clientY: 24, pointerId: 1 }));
+ target.dispatchEvent(pointerEvent('click', { clientX: 16, clientY: 24, pointerId: 1 }));
+
+ expect(gestureEvents.map(event => event.detail)).toEqual([
+ { event: move, kind: 'drag', movementX: 6, movementY: 4 }
+ ]);
+ expect(pointerInputEvents.map(event => event.detail.kind)).toEqual([
+ 'pointerdown',
+ 'pointermove',
+ 'pointerup',
+ 'click'
+ ]);
+ expect(target.setPointerCapture).toHaveBeenCalledWith(1);
+ });
+
+ it('should clear stale pointer state when a pointermove has no pressed buttons', () => {
+ target.dispatchEvent(
+ pointerEvent('pointerdown', { button: 2, buttons: 2, clientX: 10, clientY: 10, pointerId: 4 })
+ );
+ const released = pointerEvent('pointermove', { button: -1, buttons: 0, clientX: 20, clientY: 10, pointerId: 4 });
+ const resumed = pointerEvent('pointermove', { button: -1, buttons: 2, clientX: 30, clientY: 10, pointerId: 4 });
+ const restarted = pointerEvent('pointerdown', { button: 0, buttons: 1, clientX: 30, clientY: 10, pointerId: 4 });
+
+ expect(target.dispatchEvent(released)).toBe(true);
+ expect(pointerInputEvents.at(-1)?.detail).toEqual({ event: released, kind: 'pointermove' });
+ expect(target.dispatchEvent(resumed)).toBe(true);
+ expect(target.dispatchEvent(restarted)).toBe(true);
+ expect(gestureEvents).toEqual([]);
+ expect(pointerInputEvents.map(event => event.detail.kind)).toEqual(['pointermove', 'pointermove', 'pointerdown']);
+ });
+
+ it('reports pinch scale against context captured when the second pointer starts', () => {
+ target.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1, pointerType: 'touch' }));
+ target.dispatchEvent(pointerEvent('pointerdown', { clientX: 20, clientY: 10, pointerId: 2, pointerType: 'touch' }));
+ const move = pointerEvent('pointermove', { clientX: 30, clientY: 10, pointerId: 2, pointerType: 'touch' });
+
+ expect(target.dispatchEvent(move)).toBe(false);
+ expect(gestureEvents.map(event => event.detail)).toEqual([
+ { context: 'pinch-start', event: move, kind: 'pinch', scale: 2 }
+ ]);
+
+ const cancel = pointerEvent('pointercancel', { pointerId: 2, pointerType: 'touch' });
+ target.dispatchEvent(cancel);
+ expect(pointerInputEvents.at(-1)?.detail).toEqual({ event: cancel, kind: 'pointercancel' });
+
+ const remainingMove = pointerEvent('pointermove', {
+ clientX: 12,
+ clientY: 10,
+ pointerId: 1,
+ pointerType: 'touch'
+ });
+ target.dispatchEvent(remainingMove);
+ expect(gestureEvents.at(-1)?.detail).toEqual({
+ event: remainingMove,
+ kind: 'drag',
+ movementX: 2,
+ movementY: 0
+ });
+ });
+
+ it('should establish a pinch baseline after zero-distance touch input', () => {
+ target.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1, pointerType: 'touch' }));
+ target.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 2, pointerType: 'touch' }));
+ const move = pointerEvent('pointermove', { clientX: 20, clientY: 10, pointerId: 2, pointerType: 'touch' });
+
+ expect(target.dispatchEvent(move)).toBe(false);
+ expect(gestureEvents.map(event => event.detail)).toEqual([
+ { context: 'pinch-start', event: move, kind: 'pinch', scale: 1 }
+ ]);
+ });
+
+ it('consumes secondary-button boundaries and emits pan movement', () => {
+ const downstream = vi.fn();
+ target.addEventListener('pointerdown', downstream);
+ const down = pointerEvent('pointerdown', { button: 2, buttons: 2, clientX: 10, clientY: 10, pointerId: 4 });
+ const move = pointerEvent('pointermove', { button: -1, buttons: 2, clientX: 20, clientY: 15, pointerId: 4 });
+
+ expect(target.dispatchEvent(down)).toBe(false);
+ expect(target.dispatchEvent(move)).toBe(false);
+ expect(target.dispatchEvent(pointerEvent('pointerup', { button: 2, pointerId: 4 }))).toBe(false);
+ expect(target.dispatchEvent(pointerEvent('auxclick', { button: 2, pointerId: 4 }))).toBe(false);
+ expect(target.dispatchEvent(pointerEvent('click', { button: 2, pointerId: 4 }))).toBe(false);
+
+ expect(downstream).not.toHaveBeenCalled();
+ expect(gestureEvents.map(event => event.detail)).toEqual([
+ { event: move, kind: 'pan', movementX: 10, movementY: 5 }
+ ]);
+ expect(pointerInputEvents).toEqual([]);
+ });
+
+ it('normalizes wheel modes and suppresses the context menu when pan is available', () => {
+ const pixel = new WheelEvent('wheel', { cancelable: true, deltaMode: WheelEvent.DOM_DELTA_PIXEL, deltaY: 2 });
+ const line = new WheelEvent('wheel', { cancelable: true, deltaMode: WheelEvent.DOM_DELTA_LINE, deltaY: 2 });
+ const page = new WheelEvent('wheel', { cancelable: true, deltaMode: WheelEvent.DOM_DELTA_PAGE, deltaY: 2 });
+ const contextMenu = new MouseEvent('contextmenu', { cancelable: true });
+
+ expect([pixel, line].map(event => target.dispatchEvent(event))).toEqual([false, false]);
+ expect(target.getBoundingClientRect).not.toHaveBeenCalled();
+ expect(target.dispatchEvent(page)).toBe(false);
+ expect(target.getBoundingClientRect).toHaveBeenCalledOnce();
+ expect(target.dispatchEvent(contextMenu)).toBe(false);
+ expect(
+ gestureEvents.map(({ detail }) =>
+ detail.kind === 'wheel' ? { deltaPixels: detail.deltaPixels, kind: detail.kind } : null
+ )
+ ).toEqual([
+ { deltaPixels: 2, kind: 'wheel' },
+ { deltaPixels: 32, kind: 'wheel' },
+ { deltaPixels: 200, kind: 'wheel' }
+ ]);
+ });
+
+ it('honors capability and ignore gates without consuming events', () => {
+ capabilities = { drag: false, pan: false, pinch: false, wheel: false };
+ const down = pointerEvent('pointerdown', { clientX: 1, clientY: 1, pointerId: 1 });
+ const move = pointerEvent('pointermove', { clientX: 2, clientY: 1, pointerId: 1 });
+ const wheel = new WheelEvent('wheel', { cancelable: true, deltaY: 1 });
+ const contextMenu = new MouseEvent('contextmenu', { cancelable: true });
+
+ expect([down, move, wheel, contextMenu].map(event => target.dispatchEvent(event))).toEqual([
+ true,
+ true,
+ true,
+ true
+ ]);
+ expect(gestureEvents).toEqual([]);
+ expect(pointerInputEvents.map(event => event.detail.kind)).toEqual(['pointerdown', 'pointermove']);
+
+ ignored = true;
+ target.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2 }));
+ expect(pointerInputEvents.map(event => event.detail.kind)).toEqual(['pointerdown', 'pointermove']);
+ expect(prepare).toHaveBeenCalledTimes(4);
+ });
+
+ it('clears gesture state when its target changes and removes listeners on disconnect', () => {
+ target.dispatchEvent(pointerEvent('pointerdown', { clientX: 1, clientY: 1, pointerId: 1 }));
+ const replacement = document.createElement('div');
+ host.append(replacement);
+ vi.spyOn(replacement, 'setPointerCapture').mockImplementation(() => {});
+ controller.target = replacement;
+ const replacementMove = pointerEvent('pointermove', { clientX: 3, clientY: 1, pointerId: 1 });
+ replacement.dispatchEvent(replacementMove);
+
+ expect(pointerInputEvents.at(-1)?.detail).toEqual({ event: replacementMove, kind: 'pointermove' });
+ expect(gestureEvents).toEqual([]);
+
+ const callCount = pointerInputEvents.length;
+ host.remove();
+ replacement.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2 }));
+ expect(pointerInputEvents).toHaveLength(callCount);
+ });
+});
+
+function pointerEvent(type: string, init: PointerEventInit): PointerEvent {
+ return new PointerEvent(type, { bubbles: true, buttons: 1, cancelable: true, ...init });
+}
diff --git a/projects/core/src/internal/controllers/gesture.controller.ts b/projects/core/src/internal/controllers/gesture.controller.ts
new file mode 100644
index 0000000000..aaaedd7f0d
--- /dev/null
+++ b/projects/core/src/internal/controllers/gesture.controller.ts
@@ -0,0 +1,323 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ReactiveController, ReactiveControllerHost } from 'lit';
+
+const GESTURE_EVENT_TYPES = [
+ 'auxclick',
+ 'click',
+ 'contextmenu',
+ 'pointercancel',
+ 'pointerdown',
+ 'pointermove',
+ 'pointerup',
+ 'wheel'
+] as const;
+
+interface Point {
+ readonly x: number;
+ readonly y: number;
+}
+
+export interface GestureCapabilities {
+ readonly drag: boolean;
+ readonly pan: boolean;
+ readonly pinch: boolean;
+ readonly wheel: boolean;
+}
+
+interface PointerMovementGesture {
+ readonly event: PointerEvent;
+ readonly movementX: number;
+ readonly movementY: number;
+}
+
+export type Gesture =
+ | (PointerMovementGesture & { readonly kind: 'drag' })
+ | (PointerMovementGesture & { readonly kind: 'pan' })
+ | {
+ readonly context: TPinchContext;
+ readonly event: PointerEvent;
+ readonly kind: 'pinch';
+ readonly scale: number;
+ }
+ | {
+ readonly deltaPixels: number;
+ readonly event: WheelEvent;
+ readonly kind: 'wheel';
+ };
+
+export type UnhandledPointerInput =
+ | { readonly event: PointerEvent; readonly kind: 'click' }
+ | { readonly event: PointerEvent; readonly kind: 'pointercancel' }
+ | { readonly event: PointerEvent; readonly kind: 'pointerdown' }
+ | { readonly event: PointerEvent; readonly kind: 'pointermove' }
+ | { readonly event: PointerEvent; readonly kind: 'pointerup' };
+
+export interface GestureControllerOptions {
+ /** Creates a snapshot of caller state when a pinch begins. Omit this field to disable pinch recognition. */
+ readonly createPinchContext?: () => TPinchContext;
+ /** Returns the gesture kinds that the controller can recognize for the current input event. */
+ readonly getCapabilities: () => GestureCapabilities;
+ /** Updates caller state before the controller reads capabilities for a nonignored input event. */
+ readonly prepare?: () => void;
+ /** Returns whether the controller should skip an input event before preparing or recognizing it. */
+ readonly shouldIgnore?: (event: Event) => boolean;
+}
+
+interface PinchState {
+ readonly context: TPinchContext;
+ readonly distance: number;
+}
+
+type GestureHost = EventTarget & ReactiveControllerHost;
+
+/** Recognizes drag, pan, pinch, and wheel input on a dynamically assigned target. */
+export class GestureController implements ReactiveController {
+ readonly #host: GestureHost;
+ readonly #options: GestureControllerOptions;
+ readonly #panPointerIds = new Set();
+ readonly #pointerPositions = new Map();
+ #connected = false;
+ #pinch?: PinchState;
+ #target?: HTMLElement;
+
+ constructor(host: GestureHost, options: GestureControllerOptions) {
+ this.#host = host;
+ this.#options = options;
+ host.addController(this);
+ }
+
+ set target(target: HTMLElement | undefined) {
+ if (target === this.#target) return;
+ this.#removeTargetListeners();
+ this.#resetGestureState();
+ this.#target = target;
+ this.#addTargetListeners();
+ }
+
+ hostConnected(): void {
+ this.#connected = true;
+ this.#addTargetListeners();
+ }
+
+ hostDisconnected(): void {
+ this.#removeTargetListeners();
+ this.#resetGestureState();
+ this.#connected = false;
+ }
+
+ #addTargetListeners(): void {
+ if (!this.#connected || !this.#target) return;
+ for (const type of GESTURE_EVENT_TYPES) this.#target.addEventListener(type, this.#handleEvent, true);
+ }
+
+ #removeTargetListeners(): void {
+ if (!this.#connected || !this.#target) return;
+ for (const type of GESTURE_EVENT_TYPES) this.#target.removeEventListener(type, this.#handleEvent, true);
+ }
+
+ #handleEvent = (event: Event): void => {
+ const target = this.#target;
+ if (!target || event.currentTarget !== target || this.#options.shouldIgnore?.(event)) return;
+ this.#options.prepare?.();
+ const capabilities = this.#options.getCapabilities();
+ if (event instanceof WheelEvent) {
+ this.#handleWheel(event, capabilities);
+ } else if (event.type === 'contextmenu') {
+ if (capabilities.pan) event.preventDefault();
+ } else if (event instanceof PointerEvent) {
+ this.#handlePointer(event, capabilities);
+ }
+ };
+
+ #handleWheel(event: WheelEvent, capabilities: GestureCapabilities): void {
+ const target = this.#target;
+ if (!capabilities.wheel || !target) return;
+ this.#dispatchGesture({
+ deltaPixels: normalizeWheelDelta(event, target),
+ event,
+ kind: 'wheel'
+ });
+ event.preventDefault();
+ }
+
+ #handlePointer(event: PointerEvent, capabilities: GestureCapabilities): void {
+ switch (event.type) {
+ case 'auxclick':
+ case 'click':
+ this.#handleClick(event, capabilities);
+ break;
+ case 'pointercancel':
+ this.#pointerPositions.delete(event.pointerId);
+ this.#panPointerIds.delete(event.pointerId);
+ this.#pinch = undefined;
+ this.#dispatchPointerInput({ event, kind: 'pointercancel' });
+ break;
+ case 'pointerdown':
+ this.#handlePointerDown(event, capabilities);
+ break;
+ case 'pointermove':
+ this.#handlePointerMove(event, capabilities);
+ break;
+ case 'pointerup':
+ this.#handlePointerUp(event);
+ break;
+ }
+ }
+
+ #handleClick(event: PointerEvent, capabilities: GestureCapabilities): void {
+ if (event.button === 2 && capabilities.pan) {
+ consumePanBoundaryEvent(event);
+ } else if (event.type === 'click') {
+ this.#dispatchPointerInput({ event, kind: 'click' });
+ }
+ }
+
+ #handlePointerDown(event: PointerEvent, capabilities: GestureCapabilities): void {
+ const canPinch = capabilities.pinch && this.#options.createPinchContext !== undefined;
+ this.#pointerPositions.set(event.pointerId, { x: event.clientX, y: event.clientY });
+ if (event.button === 2 && capabilities.pan) this.#panPointerIds.add(event.pointerId);
+ if (hasPointerCapability(capabilities, canPinch)) this.#capturePointer(event.pointerId);
+ if (this.#panPointerIds.has(event.pointerId)) {
+ consumePanBoundaryEvent(event);
+ return;
+ }
+ if (canPinch) this.#startPinchIfNeeded();
+ this.#dispatchPointerInput({ event, kind: 'pointerdown' });
+ }
+
+ #handlePointerUp(event: PointerEvent): void {
+ this.#pointerPositions.delete(event.pointerId);
+ const wasPanPointer = this.#panPointerIds.delete(event.pointerId);
+ this.#pinch = undefined;
+ if (wasPanPointer) {
+ consumePanBoundaryEvent(event);
+ } else {
+ this.#dispatchPointerInput({ event, kind: 'pointerup' });
+ }
+ }
+
+ #handlePointerMove(event: PointerEvent, capabilities: GestureCapabilities): void {
+ if (event.buttons === 0) {
+ this.#handlePointerButtonsReleased(event);
+ return;
+ }
+ const canPinch = capabilities.pinch && this.#options.createPinchContext !== undefined;
+ const movement = this.#updatePointerPosition(event);
+ if (!movement || !hasPointerCapability(capabilities, canPinch)) {
+ return this.#dispatchPointerInput({ event, kind: 'pointermove' });
+ }
+ if (this.#handlePanMove(event, movement, capabilities)) return;
+ if (this.#pointerPositions.size >= 2 && canPinch && this.#handlePinch(event)) return;
+ if (!capabilities.drag) return this.#dispatchPointerInput({ event, kind: 'pointermove' });
+ this.#capturePointer(event.pointerId);
+ event.preventDefault();
+ this.#dispatchGesture({ event, kind: 'drag', movementX: movement.x, movementY: movement.y });
+ }
+
+ #handlePointerButtonsReleased(event: PointerEvent): void {
+ this.#pointerPositions.delete(event.pointerId);
+ this.#panPointerIds.delete(event.pointerId);
+ this.#pinch = undefined;
+ this.#dispatchPointerInput({ event, kind: 'pointermove' });
+ }
+
+ #handlePanMove(event: PointerEvent, movement: Point, capabilities: GestureCapabilities): boolean {
+ if (!capabilities.pan || !this.#panPointerIds.has(event.pointerId)) return false;
+ this.#capturePointer(event.pointerId);
+ event.preventDefault();
+ this.#dispatchGesture({ event, kind: 'pan', movementX: movement.x, movementY: movement.y });
+ return true;
+ }
+
+ #updatePointerPosition(event: PointerEvent): Point | undefined {
+ const previous = this.#pointerPositions.get(event.pointerId);
+ if (!previous) return undefined;
+ this.#pointerPositions.set(event.pointerId, { x: event.clientX, y: event.clientY });
+ const movement = { x: event.clientX - previous.x, y: event.clientY - previous.y };
+ return movement.x === 0 && movement.y === 0 ? undefined : movement;
+ }
+
+ #handlePinch(event: PointerEvent): boolean {
+ const createPinchContext = this.#options.createPinchContext;
+ if (!createPinchContext) return false;
+ const distance = this.#getPointerDistance();
+ if (distance === undefined || distance <= 0) return false;
+ this.#pinch ??= { context: createPinchContext(), distance };
+ this.#capturePointer(event.pointerId);
+ event.preventDefault();
+ this.#dispatchGesture({
+ context: this.#pinch.context,
+ event,
+ kind: 'pinch',
+ scale: distance / this.#pinch.distance
+ });
+ return true;
+ }
+
+ #startPinchIfNeeded(): void {
+ const createPinchContext = this.#options.createPinchContext;
+ if (this.#pointerPositions.size !== 2 || !createPinchContext) return;
+ const distance = this.#getPointerDistance();
+ if (distance !== undefined && distance > 0) this.#pinch = { context: createPinchContext(), distance };
+ }
+
+ #getPointerDistance(): number | undefined {
+ const pointers = [...this.#pointerPositions.values()];
+ const first = pointers[0];
+ const second = pointers[1];
+ return first && second ? Math.hypot(first.x - second.x, first.y - second.y) : undefined;
+ }
+
+ #capturePointer(pointerId: number): void {
+ try {
+ this.#target?.setPointerCapture?.(pointerId);
+ } catch {
+ // Synthetic test events and browser implementations may not have an active pointer to capture.
+ }
+ }
+
+ #dispatchGesture(gesture: Gesture): void {
+ this.#host.dispatchEvent(
+ new CustomEvent>('nve-gesture', {
+ bubbles: false,
+ cancelable: false,
+ composed: false,
+ detail: gesture
+ })
+ );
+ }
+
+ #dispatchPointerInput(input: UnhandledPointerInput): void {
+ this.#host.dispatchEvent(
+ new CustomEvent('nve-pointer-input', {
+ bubbles: false,
+ cancelable: false,
+ composed: false,
+ detail: input
+ })
+ );
+ }
+
+ #resetGestureState(): void {
+ this.#pointerPositions.clear();
+ this.#panPointerIds.clear();
+ this.#pinch = undefined;
+ }
+}
+
+function hasPointerCapability(capabilities: GestureCapabilities, canPinch: boolean): boolean {
+ return capabilities.drag || capabilities.pan || canPinch;
+}
+
+function consumePanBoundaryEvent(event: Event): void {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+}
+
+function normalizeWheelDelta(event: WheelEvent, target: HTMLElement): number {
+ if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16;
+ if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * target.getBoundingClientRect().height;
+ return event.deltaY;
+}
diff --git a/projects/core/src/internal/controllers/keynav-spatial.controller.test.ts b/projects/core/src/internal/controllers/keynav-spatial.controller.test.ts
new file mode 100644
index 0000000000..adb9927ebc
--- /dev/null
+++ b/projects/core/src/internal/controllers/keynav-spatial.controller.test.ts
@@ -0,0 +1,115 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { html, type ReactiveController } from 'lit';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createFixture, removeFixture } from '@internals/testing';
+import { KeyNavigationSpatialController, type SpatialKeyCommand } from './keynav-spatial.controller.js';
+
+class KeyNavigationSpatialControllerTestHost extends HTMLElement {
+ readonly #controllers = new Set();
+ readonly updateComplete = Promise.resolve(true);
+
+ addController(controller: ReactiveController): void {
+ this.#controllers.add(controller);
+ }
+
+ removeController(controller: ReactiveController): void {
+ this.#controllers.delete(controller);
+ }
+
+ requestUpdate(): void {}
+
+ connectedCallback(): void {
+ this.#controllers.forEach(controller => controller.hostConnected?.());
+ }
+
+ disconnectedCallback(): void {
+ this.#controllers.forEach(controller => controller.hostDisconnected?.());
+ }
+}
+
+const tag = 'keynav-spatial-controller-test-host';
+if (!customElements.get(tag)) customElements.define(tag, KeyNavigationSpatialControllerTestHost);
+
+describe('KeyNavigationSpatialController', () => {
+ let fixture: HTMLElement;
+ let host: KeyNavigationSpatialControllerTestHost;
+ let events: CustomEvent[];
+
+ beforeEach(async () => {
+ fixture = await createFixture(html``);
+ host = document.createElement(tag) as KeyNavigationSpatialControllerTestHost;
+ events = [];
+ new KeyNavigationSpatialController(host);
+ host.addEventListener('nve-key', event => events.push(event as CustomEvent));
+ fixture.append(host);
+ });
+
+ afterEach(() => removeFixture(fixture));
+
+ it('synchronously dispatches normalized directional and zoom commands on the host', () => {
+ const sourceEvents = [
+ new KeyboardEvent('keydown', { cancelable: true, key: 'ArrowLeft', shiftKey: true }),
+ new KeyboardEvent('keydown', { cancelable: true, key: 'ArrowUp' }),
+ new KeyboardEvent('keydown', { cancelable: true, ctrlKey: true, key: 'ArrowDown', shiftKey: true }),
+ new KeyboardEvent('keydown', { cancelable: true, key: '+' }),
+ new KeyboardEvent('keydown', { cancelable: true, key: '=' }),
+ new KeyboardEvent('keydown', { cancelable: true, key: '-' })
+ ];
+
+ expect(sourceEvents.map(event => host.dispatchEvent(event))).toEqual([true, true, true, true, true, true]);
+ expect(events.map(event => event.detail.event)).toEqual(sourceEvents);
+ expect(events.map(({ detail: { event: _, ...command } }) => command)).toEqual([
+ { ctrlKey: false, horizontal: -1, key: 'ArrowLeft', kind: 'direction', shiftKey: true, vertical: 0 },
+ { ctrlKey: false, horizontal: 0, key: 'ArrowUp', kind: 'direction', shiftKey: false, vertical: 1 },
+ { ctrlKey: true, horizontal: 0, key: 'ArrowDown', kind: 'direction', shiftKey: true, vertical: -1 },
+ { key: '+', kind: 'zoom' },
+ { key: '=', kind: 'zoom' },
+ { key: '-', kind: 'zoom' }
+ ]);
+ expect(events[0]).toMatchObject({ bubbles: false, cancelable: false, composed: false, target: host });
+ expect(sourceEvents.every(event => !event.defaultPrevented)).toBe(true);
+ });
+
+ it('keeps keynav events internal and leaves descendant and unrecognized input alone', () => {
+ const ancestorListener = vi.fn();
+ fixture.addEventListener('nve-key', ancestorListener);
+ const child = document.createElement('button');
+ host.append(child);
+ const descendant = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowRight' });
+ const recognized = new KeyboardEvent('keydown', { cancelable: true, key: 'ArrowDown' });
+ const unrecognized = new KeyboardEvent('keydown', { cancelable: true, key: 'x' });
+
+ expect(child.dispatchEvent(descendant)).toBe(true);
+ expect(host.dispatchEvent(recognized)).toBe(true);
+ expect(host.dispatchEvent(unrecognized)).toBe(true);
+ expect(events).toHaveLength(1);
+ expect(events[0]?.detail).toMatchObject({
+ ctrlKey: false,
+ event: recognized,
+ horizontal: 0,
+ key: 'ArrowDown',
+ kind: 'direction',
+ shiftKey: false,
+ vertical: -1
+ });
+ expect(ancestorListener).not.toHaveBeenCalled();
+ });
+
+ it('should ignore composed keydown events from shadow descendants', () => {
+ const shadowChild = document.createElement('button');
+ host.attachShadow({ mode: 'open' }).append(shadowChild);
+
+ shadowChild.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'ArrowRight' }));
+
+ expect(events).toEqual([]);
+ });
+
+ it('removes its listener when the host disconnects', () => {
+ host.remove();
+ host.dispatchEvent(new KeyboardEvent('keydown', { cancelable: true, key: 'ArrowRight' }));
+
+ expect(events).toEqual([]);
+ });
+});
diff --git a/projects/core/src/internal/controllers/keynav-spatial.controller.ts b/projects/core/src/internal/controllers/keynav-spatial.controller.ts
new file mode 100644
index 0000000000..6ff5d6ec0e
--- /dev/null
+++ b/projects/core/src/internal/controllers/keynav-spatial.controller.ts
@@ -0,0 +1,107 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ReactiveController, ReactiveControllerHost } from 'lit';
+
+type SpatialKeyHost = ReactiveControllerHost & HTMLElement;
+
+export type SpatialKeyHandling = 'handled' | 'ignored';
+
+export type SpatialKeyCommand =
+ | {
+ readonly ctrlKey: boolean;
+ readonly event: KeyboardEvent;
+ readonly kind: 'direction';
+ readonly horizontal: -1 | 0 | 1;
+ readonly key: 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'ArrowUp';
+ readonly shiftKey: boolean;
+ readonly vertical: -1 | 0 | 1;
+ }
+ | {
+ readonly event: KeyboardEvent;
+ readonly kind: 'zoom';
+ readonly key: '+' | '-' | '=';
+ };
+
+/** Converts direct-host keyboard input into spatial direction and zoom commands. */
+export class KeyNavigationSpatialController implements ReactiveController {
+ readonly #host: SpatialKeyHost;
+
+ constructor(host: SpatialKeyHost) {
+ this.#host = host;
+ host.addController(this);
+ }
+
+ hostConnected(): void {
+ this.#host.addEventListener('keydown', this.#handleKeydown);
+ }
+
+ hostDisconnected(): void {
+ this.#host.removeEventListener('keydown', this.#handleKeydown);
+ }
+
+ #handleKeydown = (event: KeyboardEvent): void => {
+ if (event.composedPath()[0] !== this.#host) return;
+ const command = getSpatialKeyCommand(event);
+ if (!command) return;
+ this.#host.dispatchEvent(
+ new CustomEvent('nve-key', {
+ bubbles: false,
+ cancelable: false,
+ composed: false,
+ detail: command
+ })
+ );
+ };
+}
+
+function getSpatialKeyCommand(event: KeyboardEvent): SpatialKeyCommand | undefined {
+ switch (event.key) {
+ case 'ArrowDown':
+ return {
+ ctrlKey: event.ctrlKey,
+ event,
+ horizontal: 0,
+ key: event.key,
+ kind: 'direction',
+ shiftKey: event.shiftKey,
+ vertical: -1
+ };
+ case 'ArrowLeft':
+ return {
+ ctrlKey: event.ctrlKey,
+ event,
+ horizontal: -1,
+ key: event.key,
+ kind: 'direction',
+ shiftKey: event.shiftKey,
+ vertical: 0
+ };
+ case 'ArrowRight':
+ return {
+ ctrlKey: event.ctrlKey,
+ event,
+ horizontal: 1,
+ key: event.key,
+ kind: 'direction',
+ shiftKey: event.shiftKey,
+ vertical: 0
+ };
+ case 'ArrowUp':
+ return {
+ ctrlKey: event.ctrlKey,
+ event,
+ horizontal: 0,
+ key: event.key,
+ kind: 'direction',
+ shiftKey: event.shiftKey,
+ vertical: 1
+ };
+ case '+':
+ case '-':
+ case '=':
+ return { event, key: event.key, kind: 'zoom' };
+ default:
+ return undefined;
+ }
+}
diff --git a/projects/core/src/internal/controllers/type-touch.controller.examples.ts b/projects/core/src/internal/controllers/type-touch.controller.examples.ts
deleted file mode 100644
index e8171a18bd..0000000000
--- a/projects/core/src/internal/controllers/type-touch.controller.examples.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import { css, html, LitElement } from 'lit';
-import { typeTouch } from '@nvidia-elements/core/internal';
-import '@nvidia-elements/core/card/define.js';
-import '@nvidia-elements/core/button/define.js';
-import '@nvidia-elements/core/icon-button/define.js';
-
-export default {
- title: 'Internal/Controllers'
-}
-
-@typeTouch()
-class TouchDemoElement extends LitElement {
- static styles = [
- css`
- :host {
- display: block;
- position: fixed;
- width: 50px;
- height: 50px;
- outline: 1px solid red;
- }
- `,
- ];
-}
-
-customElements.get('type-touch-controller-demo-element') || customElements.define('type-touch-controller-demo-element', TouchDemoElement);
-
-/**
- * @summary Touch controller with drag-to-move behavior dispatching start, move, and end events.
- * @tags test-case
- */
-export const TouchDemo = {
- render: () => html`
-
-
- `
-}
diff --git a/projects/core/src/internal/controllers/type-touch.controller.test.ts b/projects/core/src/internal/controllers/type-touch.controller.test.ts
deleted file mode 100644
index a1ec7784cb..0000000000
--- a/projects/core/src/internal/controllers/type-touch.controller.test.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { css, html, LitElement } from 'lit';
-import { customElement } from 'lit/decorators/custom-element.js';
-import { createFixture, elementIsStable, removeFixture, untilEvent } from '@internals/testing';
-import { typeTouch } from '@nvidia-elements/core/internal';
-import type { NveTouchEvent } from '@nvidia-elements/core/internal';
-
-@typeTouch()
-@customElement('type-touch-controller-test-element')
-class TypeTouchControllerTestElement extends LitElement {
- // using an internal relative block to get more accurate offset values
- static styles = [
- css`
- :host {
- display: block;
- position: relative;
- width: 100px;
- height: 100px;
- }
-
- button {
- position: absolute;
- top: 20px;
- left: 20px;
- }
- `
- ];
-
- render() {
- return html``;
- }
-}
-
-describe('touch.controller', () => {
- let element: TypeTouchControllerTestElement;
- let fixture: HTMLElement;
-
- beforeEach(async () => {
- fixture = await createFixture(html``);
- element = fixture.querySelector('type-touch-controller-test-element');
- });
-
- afterEach(() => {
- removeFixture(fixture);
- });
-
- it('should trigger nve-touch-start on pointerdown', async () => {
- await elementIsStable(element);
- const event = untilEvent(element, 'nve-touch-start');
- element.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1 }));
- expect(await event).toBeTruthy();
- });
-
- it('should ignore pointerdown events outside the composed path', async () => {
- await elementIsStable(element);
- const event = new PointerEvent('pointerdown', { pointerId: 1 });
- const listener = vi.fn();
- const setPointerCapture = vi.spyOn(element, 'setPointerCapture');
-
- vi.spyOn(event, 'composedPath').mockReturnValue([]);
- element.addEventListener('nve-touch-start', listener);
- element.dispatchEvent(event);
-
- expect(listener).not.toHaveBeenCalled();
- expect(setPointerCapture).not.toHaveBeenCalled();
- });
-
- it('should trigger nve-touch-end on pointerup', async () => {
- await elementIsStable(element);
- const startEvent = untilEvent(element, 'nve-touch-start');
- const endEvent = untilEvent(element, 'nve-touch-end');
-
- element.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1 }));
- expect(await startEvent).toBeTruthy();
-
- document.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
- expect(await endEvent).toBeTruthy();
- });
-
- it('should return coordinates of touchstart event', async () => {
- await elementIsStable(element);
- const event = untilEvent(element, 'nve-touch-start');
-
- element.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1 }));
- const touchEvent = await event;
- expect(touchEvent.x).toBe(0);
- expect(touchEvent.y).toBe(0);
- expect(touchEvent.offsetX).toBe(0);
- expect(touchEvent.offsetY).toBe(0);
- });
-
- it('should return coordinates of touchmove event', async () => {
- await elementIsStable(element);
- const event = untilEvent(element, 'nve-touch-move');
- element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 0, clientY: 0, pointerId: 1 }));
- document.dispatchEvent(new PointerEvent('pointermove', { clientX: 75, clientY: 75, pointerId: 1 }));
-
- const touchEvent = await event;
- expect(touchEvent.x).toEqual(75);
- expect(touchEvent.y).toEqual(75);
- expect(touchEvent.offsetX).toEqual(75);
- expect(touchEvent.offsetY).toEqual(75);
- });
-
- it('should return coordinates of touchend event', async () => {
- await elementIsStable(element);
- const event = untilEvent(element, 'nve-touch-end');
-
- element.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1 }));
- document.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
- const touchEvent = await event;
- expect(touchEvent.x).toBe(0);
- expect(touchEvent.y).toBe(0);
- expect(touchEvent.offsetX).toBe(0);
- expect(touchEvent.offsetY).toBe(0);
- });
-
- it('should return offset value from initial starting point of touchstart', async () => {
- await elementIsStable(element);
- const event = untilEvent(element, 'nve-touch-end');
- element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 20, clientY: 10, pointerId: 1 }));
- document.dispatchEvent(new PointerEvent('pointerup', { clientX: 40, clientY: 20, pointerId: 1 }));
-
- const touchEvent = await event;
- expect(touchEvent.x).toEqual(40);
- expect(touchEvent.y).toEqual(20);
- expect(touchEvent.offsetX).toEqual(20);
- expect(touchEvent.offsetY).toEqual(10);
- });
-});
diff --git a/projects/core/src/internal/controllers/type-touch.controller.ts b/projects/core/src/internal/controllers/type-touch.controller.ts
deleted file mode 100644
index cb402a1b95..0000000000
--- a/projects/core/src/internal/controllers/type-touch.controller.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-import type { ReactiveElement, ReactiveController } from 'lit';
-import type { LegacyDecoratorTarget, OffsetPoint, Point } from '../types/index.js';
-import { getDifference } from '../utils/objects.js';
-
-export class NveTouchEvent extends Event {
- x: number;
- y: number;
- offsetX: number;
- offsetY: number;
-
- constructor(event: 'nve-touch-start' | 'nve-touch-move' | 'nve-touch-end', point: Point & OffsetPoint) {
- super(event);
- this.x = point.x;
- this.y = point.y;
- this.offsetX = point.offsetX;
- this.offsetY = point.offsetY;
- }
-}
-
-/**
- * @event nve-touch-start
- * @event nve-touch-move
- * @event nve-touch-end
- */
-export function typeTouch(): ClassDecorator {
- return (target: LegacyDecoratorTarget) => target.addInitializer!((instance: T) => new TypeTouchController(instance));
-}
-
-export class TypeTouchController implements ReactiveController {
- #startPosition: Point;
- #pointerId: number;
-
- constructor(private host: T) {
- this.host.addController(this);
- }
-
- hostConnected() {
- this.host.addEventListener('pointerdown', this.#start);
- }
-
- hostDisconnected() {
- this.host.removeEventListener('pointerdown', this.#start);
- globalThis.document.removeEventListener('pointerup', this.#end);
- globalThis.document.removeEventListener('pointermove', this.#move);
- }
-
- #start = (e: PointerEvent) => {
- if (e.composedPath().find(el => el === this.host)) {
- this.#startPosition = { x: e.clientX, y: e.clientY };
- this.#pointerId = e.pointerId;
- this.host.setPointerCapture(this.#pointerId);
- globalThis.document.addEventListener('pointerup', this.#end);
- globalThis.document.addEventListener('pointermove', this.#move);
- this.host.dispatchEvent(new NveTouchEvent('nve-touch-start', { ...this.#startPosition, offsetX: 0, offsetY: 0 }));
- }
- };
-
- #move = (e: PointerEvent) => {
- requestAnimationFrame(() => {
- const point = this.#getCoordinatesFromPointerEvent(e);
- this.host.dispatchEvent(new NveTouchEvent('nve-touch-move', point));
- this.#startPosition = { x: e.clientX, y: e.clientY };
- });
- };
-
- #end = (e: PointerEvent) => {
- /* istanbul ignore else -- #start registers #end only after setting #startPosition. */
- if (this.#startPosition) {
- globalThis.document.removeEventListener('pointerup', this.#end, false);
- globalThis.document.removeEventListener('pointermove', this.#move, false);
- this.host.dispatchEvent(new NveTouchEvent('nve-touch-end', this.#getCoordinatesFromPointerEvent(e)));
- this.host.releasePointerCapture(this.#pointerId);
- this.#pointerId = null as unknown as number;
- }
- };
-
- #getCoordinatesFromPointerEvent(e: PointerEvent) {
- const value = {
- offsetX: getDifference(this.#startPosition.x, e.clientX),
- offsetY: getDifference(this.#startPosition.y, e.clientY),
- x: e.clientX,
- y: e.clientY
- };
- return value;
- }
-}
diff --git a/projects/core/src/internal/index.ts b/projects/core/src/internal/index.ts
index f42c160bd4..30924caa13 100644
--- a/projects/core/src/internal/index.ts
+++ b/projects/core/src/internal/index.ts
@@ -5,9 +5,11 @@
* @internal private common utilities for Elements
*/
export * from './controllers/audit.controller.js';
+export * from './controllers/gesture.controller.js';
export * from './controllers/i18n.controller.js';
export * from './controllers/keynav-grid.controller.js';
export * from './controllers/keynav-list.controller.js';
+export * from './controllers/keynav-spatial.controller.js';
export * from './controllers/state-expanded.controller.js';
export * from './controllers/state-highlighted.controller.js';
export * from './controllers/state-scroll.controller.js';
@@ -18,7 +20,6 @@ export * from './controllers/type-expandable.controller.js';
export * from './controllers/type-selectable.controller.js';
export * from './controllers/type-native-popover.controller.js';
export * from './controllers/type-ssr.controller.js';
-export * from './controllers/type-touch.controller.js';
export * from './decorators/host-attr.js';
export * from './decorators/scoped-registry.js';
export * from './styles/index.js';
diff --git a/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts b/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts
index d95ac95bfd..d41350c9fd 100644
--- a/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts
+++ b/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts
@@ -16,6 +16,6 @@ describe('resize-handle lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
- expect(report.payload.javascript.kb).toBeLessThan(13.5);
+ expect(report.payload.javascript.kb).toBeLessThan(14.5);
});
});
diff --git a/projects/core/src/resize-handle/resize-handle.test.ts b/projects/core/src/resize-handle/resize-handle.test.ts
index fc57d41a06..c65a0532b3 100644
--- a/projects/core/src/resize-handle/resize-handle.test.ts
+++ b/projects/core/src/resize-handle/resize-handle.test.ts
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { html } from 'lit';
-import { describe, expect, it, beforeEach, afterEach } from 'vitest';
+import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { createFixture, removeFixture, elementIsStable, untilEvent } from '@internals/testing';
import { ResizeHandle } from '@nvidia-elements/core/resize-handle';
import '@nvidia-elements/core/resize-handle/define.js';
@@ -25,6 +25,7 @@ describe(ResizeHandle.metadata.tag, () => {
afterEach(() => {
removeFixture(fixture);
+ vi.restoreAllMocks();
});
it('should define element', () => {
@@ -123,41 +124,142 @@ describe(ResizeHandle.metadata.tag, () => {
expect(element.validity.tooShort).toBe(false);
});
- it('should support touch start event states', async () => {
- const event = untilEvent(element, 'nve-touch-start');
- element.dispatchEvent(new CustomEvent('nve-touch-start'));
- await event;
+ it('should activate with a temporary step and capture the pointer on pointerdown', () => {
+ const setPointerCapture = vi.spyOn(element, 'setPointerCapture').mockImplementation(() => {});
+ element.dispatchEvent(
+ pointerEvent('pointerdown', { button: 0, clientX: 10, clientY: 20, isPrimary: true, pointerId: 3 })
+ );
+
expect(input.step).toBe('1');
- expect(element.matches(':state(active)'));
+ expect(element.matches(':state(active)')).toBe(true);
+ expect(setPointerCapture).toHaveBeenCalledWith(3);
});
- it('should support touch end event states', async () => {
- const event = untilEvent(element, 'nve-touch-end');
- element.dispatchEvent(new CustomEvent('nve-touch-end'));
- await event;
- expect(input.step).toBe('10');
- expect(element.matches(':not(:state(active))'));
+ it('should ignore secondary and nonprimary pointerdown events', () => {
+ [
+ { button: 1, isPrimary: true, pointerId: 1 },
+ { button: 0, isPrimary: false, pointerId: 2 }
+ ].forEach(init => {
+ element.dispatchEvent(pointerEvent('pointerdown', init));
+ expect(input.step).toBe('10');
+ expect(element.matches(':not(:state(active))')).toBe(true);
+ });
+
+ element.dispatchEvent(pointerEvent('pointerdown', { button: 0, isPrimary: true, pointerId: 3 }));
+ expect(element.matches(':state(active)')).toBe(true);
});
- it('should support touch move event states', async () => {
- const event = untilEvent(element, 'nve-touch-move');
- element.dispatchEvent(new CustomEvent('nve-touch-move'));
- await event;
- await new Promise(r => requestAnimationFrame(r));
+ it('should ignore pointer input before its internal range is ready', async () => {
+ const pending = document.createElement(ResizeHandle.metadata.tag) as ResizeHandle;
+ fixture.append(pending);
- expect(element.valueAsNumber).toBe(50);
+ expect(() =>
+ pending.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }))
+ ).not.toThrow();
+ expect(pending.matches(':not(:state(active))')).toBe(true);
+
+ await elementIsStable(pending);
+ pending.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }));
+ expect(pending.matches(':state(active)')).toBe(true);
+ });
+
+ it('should ignore pointerup when no matching drag is active', () => {
+ const changeListener = vi.fn();
+ element.addEventListener('change', changeListener);
+
+ element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }));
+ element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2 }));
+ element.dispatchEvent(pointerEvent('pointerup', { pointerId: 3 }));
+
+ expect(changeListener).not.toHaveBeenCalled();
+ expect(input.step).toBe('1');
+ expect(element.matches(':state(active)')).toBe(true);
+ });
+
+ it('should update horizontal values and dispatch input during drag', async () => {
+ const inputEvent = untilEvent(element, 'input');
+ const changeListener = vi.fn();
+ element.addEventListener('change', changeListener);
+ element.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }));
+ const move = pointerEvent('pointermove', { clientX: 10, clientY: 10, pointerId: 1 });
+
+ expect(element.dispatchEvent(move)).toBe(false);
+ await inputEvent;
+
+ expect(element.valueAsNumber).toBe(60);
+ expect(changeListener).not.toHaveBeenCalled();
+ });
+
+ it('should clamp drag offsets and remain responsive at either boundary', async () => {
+ element.min = 10;
+ element.max = 90;
+ await elementIsStable(element);
+ element.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }));
+
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 10, clientY: -80, pointerId: 1 }));
+ expect(element.valueAsNumber).toBe(90);
+
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 10, clientY: -70, pointerId: 1 }));
+ expect(element.valueAsNumber).toBe(80);
+
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 10, clientY: 120, pointerId: 1 }));
+ expect(element.valueAsNumber).toBe(10);
+
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 10, clientY: 110, pointerId: 1 }));
+ expect(element.valueAsNumber).toBe(20);
});
- it('should support touch move event states for vertical orientation', async () => {
+ it('should use horizontal movement for vertical orientation', async () => {
element.orientation = 'vertical';
await elementIsStable(element);
+ const inputEvent = untilEvent(element, 'input');
+ element.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }));
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 20, clientY: 20, pointerId: 1 }));
- const event = untilEvent(element, 'nve-touch-move');
- element.dispatchEvent(new CustomEvent('nve-touch-move'));
- await event;
- await new Promise(r => requestAnimationFrame(r));
+ await inputEvent;
- expect(element.valueAsNumber).toBe(50);
+ expect(element.valueAsNumber).toBe(60);
+ });
+
+ it('should reverse drag movement in rtl direction', async () => {
+ element.dir = 'rtl';
+ const inputEvent = untilEvent(element, 'input');
+ element.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }));
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 10, clientY: 10, pointerId: 1 }));
+
+ await inputEvent;
+
+ expect(element.valueAsNumber).toBe(40);
+ });
+
+ it('should restore its step and dispatch a final change on pointerup', async () => {
+ const changeEvent = untilEvent(element, 'change');
+ const changeListener = vi.fn();
+ element.addEventListener('change', changeListener);
+ element.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }));
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 10, clientY: 10, pointerId: 1 }));
+ element.dispatchEvent(pointerEvent('pointerup', { clientX: 10, clientY: 10, pointerId: 1 }));
+
+ await changeEvent;
+
+ expect(element.valueAsNumber).toBe(60);
+ expect(input.step).toBe('10');
+ expect(element.matches(':not(:state(active))')).toBe(true);
+ expect(changeListener).toHaveBeenCalledOnce();
+ });
+
+ it('should restore drag state after a matching pointercancel and support subsequent drags', async () => {
+ element.step = 5;
+ await elementIsStable(element);
+ element.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 1 }));
+ element.dispatchEvent(pointerEvent('pointercancel', { pointerId: 1 }));
+
+ expect(input.step).toBe('5');
+ expect(element.matches(':not(:state(active))')).toBe(true);
+
+ element.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 20, pointerId: 2 }));
+ expect(element.dispatchEvent(pointerEvent('pointermove', { clientX: 10, clientY: 10, pointerId: 2 }))).toBe(false);
+ expect(element.valueAsNumber).toBe(60);
});
it('should support snap to value on double click', async () => {
@@ -192,3 +294,7 @@ describe(ResizeHandle.metadata.tag, () => {
expect(element.valueAsNumber).toBe(50);
});
});
+
+function pointerEvent(type: string, init: PointerEventInit): PointerEvent {
+ return new PointerEvent(type, { bubbles: true, buttons: 1, cancelable: true, isPrimary: true, ...init });
+}
diff --git a/projects/core/src/resize-handle/resize-handle.ts b/projects/core/src/resize-handle/resize-handle.ts
index 90a606803b..89c1f6c1fd 100644
--- a/projects/core/src/resize-handle/resize-handle.ts
+++ b/projects/core/src/resize-handle/resize-handle.ts
@@ -5,8 +5,13 @@ import { html, LitElement, type PropertyValues } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { property } from 'lit/decorators/property.js';
import { FormControlMixin } from '@nvidia-elements/forms/mixins';
-import { useStyles, typeTouch } from '@nvidia-elements/core/internal';
-import { I18nController, type NveTouchEvent } from '@nvidia-elements/core/internal';
+import {
+ GestureController,
+ I18nController,
+ useStyles,
+ type Gesture,
+ type UnhandledPointerInput
+} from '@nvidia-elements/core/internal';
import styles from './resize-handle.css?inline';
/**
@@ -24,7 +29,6 @@ import styles from './resize-handle.css?inline';
* @aria https://www.w3.org/WAI/ARIA/apg/patterns/slider/
*
*/
-@typeTouch()
export class ResizeHandle extends FormControlMixin(LitElement) {
/**
* Determines the orientation direction of the resize handle.
@@ -60,7 +64,11 @@ export class ResizeHandle extends FormControlMixin(Li
}
};
- #i18nController: I18nController = new I18nController(this);
+ readonly #i18nController: I18nController = new I18nController(this);
+
+ readonly #gestureController: GestureController;
+
+ #dragPointerId?: number;
/**
* Updates internal string values for internationalization.
@@ -82,18 +90,28 @@ export class ResizeHandle extends FormControlMixin(Li
super();
this.value = this.value ?? 50;
this.#offset = this.valueAsNumber;
+ this.#gestureController = new GestureController(this, {
+ getCapabilities: () => ({ drag: true, pan: false, pinch: false, wheel: false })
+ });
}
connectedCallback() {
super.connectedCallback();
this._internals.role = 'none';
+ this.addEventListener('nve-gesture', this.#handleGesture as EventListener);
+ this.addEventListener('nve-pointer-input', this.#handlePointerInput as EventListener);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener('nve-gesture', this.#handleGesture as EventListener);
+ this.removeEventListener('nve-pointer-input', this.#handlePointerInput as EventListener);
+ this.#cancelDrag();
+ super.disconnectedCallback();
}
firstUpdated(props: PropertyValues) {
super.firstUpdated(props);
- this.addEventListener('nve-touch-start', () => this.#touchStart());
- this.addEventListener('nve-touch-end', () => this.#touchEnd());
- this.addEventListener('nve-touch-move', ((e: NveTouchEvent) => this.#touchMove(e)) as EventListener);
+ this.#gestureController.target = this;
this.addEventListener('dblclick', () => {
if (!this.dispatchEvent(new CustomEvent('toggle', { cancelable: true, bubbles: true, composed: true }))) {
return;
@@ -108,22 +126,52 @@ export class ResizeHandle extends FormControlMixin(Li
this.#setChange(value);
}
- #touchStart() {
+ #handlePointerInput = (event: CustomEvent): void => {
+ if (
+ event.detail.kind === 'pointerdown' &&
+ event.detail.event.button === 0 &&
+ event.detail.event.isPrimary &&
+ this.#dragPointerId === undefined
+ ) {
+ this.#dragStart(event.detail.event.pointerId);
+ }
+ if (event.detail.event.pointerId !== this.#dragPointerId) return;
+ if (event.detail.kind === 'pointerup') this.#dragEnd();
+ if (event.detail.kind === 'pointercancel') this.#cancelDrag();
+ };
+
+ #handleGesture = (event: CustomEvent>): void => {
+ if (event.detail.kind === 'drag' && event.detail.event.pointerId === this.#dragPointerId) {
+ this.#dragMove(event.detail);
+ }
+ };
+
+ #dragStart(pointerId: number) {
+ this.#dragPointerId = pointerId;
this.#range.step = '1';
this._internals.states.add('active');
this.#offset = this.valueAsNumber;
}
- #touchMove(e: NveTouchEvent) {
- const offset = (this.orientation === 'vertical' ? e.offsetX : -e.offsetY) * (this.dir === 'rtl' ? -1 : 1);
- this.#offset = this.#offset + offset;
+ #dragMove(gesture: Extract, { kind: 'drag' }>) {
+ const offset =
+ (this.orientation === 'vertical' ? gesture.movementX : -gesture.movementY) * (this.dir === 'rtl' ? -1 : 1);
+ this.#offset = Math.max(this.min, Math.min(this.max, this.#offset + offset));
this.#setInput(this.#offset);
}
- #touchEnd() {
+ #dragEnd() {
this.#offset = this.valueAsNumber;
this.#range.step = `${this.step}`;
+ this.#dragPointerId = undefined;
+ this._internals.states.delete('active');
this.#setChange(this.#offset);
+ }
+
+ #cancelDrag() {
+ if (this.#dragPointerId === undefined) return;
+ this.#dragPointerId = undefined;
+ this.#range.step = `${this.step}`;
this._internals.states.delete('active');
}