From f793824eef77a9bbb4352e1b8577bf891366da9c Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Wed, 26 Aug 2026 13:18:12 -0500 Subject: [PATCH] feat(core): add internal gesture and key navigation controllers - Introduced `GestureController` to handle drag, pan, pinch, and wheel gestures. - Added `KeyNavigationSpatialController` for keyboard navigation with directional and zoom commands. - Updated `ResizeHandle` to utilize the new gesture controller for drag functionality. - Removed deprecated touch controller files and examples. Signed-off-by: Cory Rylan --- projects/core/src/index.test.lighthouse.ts | 4 +- .../gesture.controller.examples.ts | 69 ++++ .../controllers/gesture.controller.test.ts | 345 ++++++++++++++++++ .../controllers/gesture.controller.ts | 323 ++++++++++++++++ .../keynav-spatial.controller.test.ts | 115 ++++++ .../controllers/keynav-spatial.controller.ts | 107 ++++++ .../type-touch.controller.examples.ts | 50 --- .../controllers/type-touch.controller.test.ts | 133 ------- .../controllers/type-touch.controller.ts | 89 ----- projects/core/src/internal/index.ts | 3 +- .../resize-handle.test.lighthouse.ts | 2 +- .../src/resize-handle/resize-handle.test.ts | 154 ++++++-- .../core/src/resize-handle/resize-handle.ts | 72 +++- 13 files changed, 1154 insertions(+), 312 deletions(-) create mode 100644 projects/core/src/internal/controllers/gesture.controller.examples.ts create mode 100644 projects/core/src/internal/controllers/gesture.controller.test.ts create mode 100644 projects/core/src/internal/controllers/gesture.controller.ts create mode 100644 projects/core/src/internal/controllers/keynav-spatial.controller.test.ts create mode 100644 projects/core/src/internal/controllers/keynav-spatial.controller.ts delete mode 100644 projects/core/src/internal/controllers/type-touch.controller.examples.ts delete mode 100644 projects/core/src/internal/controllers/type-touch.controller.test.ts delete mode 100644 projects/core/src/internal/controllers/type-touch.controller.ts 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'); }