diff --git a/src/primitives/useMouse.ts b/src/primitives/useMouse.ts index c1a243d..e3d5e24 100644 --- a/src/primitives/useMouse.ts +++ b/src/primitives/useMouse.ts @@ -51,7 +51,7 @@ function createKeyboardEvent( keyCode: number, eventName: string = 'keydown', ): KeyboardEvent { - return new KeyboardEvent(eventName, { + const event = new KeyboardEvent(eventName, { key, keyCode, which: keyCode, @@ -61,6 +61,18 @@ function createKeyboardEvent( metaKey: false, bubbles: true, }); + + // Chrome < 51 (LG webOS 3.x runs Chrome 38) ignores the key/keyCode/which + // members of KeyboardEventInit, so the event arrives with key '' / keyCode 0 + // and the focusManager drops it. Force the values onto the instance so Magic + // Remote clicks and scrolls still map to keys on those devices. + if (event.key !== key || event.keyCode !== keyCode) { + Object.defineProperty(event, 'key', { get: () => key }); + Object.defineProperty(event, 'keyCode', { get: () => keyCode }); + Object.defineProperty(event, 'which', { get: () => keyCode }); + } + + return event; } const [cursorVisible, setCursorVisible] = createSignal(false); diff --git a/tests/useMouse.test.tsx b/tests/useMouse.test.tsx index fb3fd74..5660974 100644 --- a/tests/useMouse.test.tsx +++ b/tests/useMouse.test.tsx @@ -1,6 +1,7 @@ import * as v from 'vitest'; import * as lng from '@solidtv/solid'; import { useMouse } from '../src/primitives/useMouse.js'; +import { useFocusManager } from '../src/core/focusManager.js'; import { renderer } from './setup.js'; const dispatchClick = (x: number, y: number) => @@ -414,3 +415,162 @@ v.describe('useMouse', () => { dispose(); }); }); + +v.describe('synthetic keyboard events (legacy KeyboardEvent constructors)', () => { + // Simulates Chrome < 51 (LG webOS 3.x), where the KeyboardEvent constructor + // exists but silently drops the key/keyCode/which members of the init dict. + class LegacyKeyboardEvent extends KeyboardEvent { + constructor(type: string, init?: KeyboardEventInit) { + super(type, { bubbles: init?.bubbles, cancelable: init?.cancelable }); + } + } + + const captureKeydown = () => { + const events: KeyboardEvent[] = []; + const listener = (e: Event) => events.push(e as KeyboardEvent); + document.addEventListener('keydown', listener); + return { + events, + stop: () => document.removeEventListener('keydown', listener), + }; + }; + + v.afterEach(() => { + v.vi.unstubAllGlobals(); + }); + + v.test('modern engine: click dispatches Enter via the constructor path (no defineProperty)', async () => { + let app!: lng.ElementNode; + let btn!: lng.ElementNode; + + const dispose = renderer.render(() => { + const root = ( + + {}} + /> + + ); + useMouse(app, 5); + return root; + }); + + await wait(20); + btn.setFocus(); + await wait(20); + v.assert.equal(lng.activeElement(), btn); + + // attach after the waits so stray delayed dispatches from prior tests + // have flushed + const capture = captureKeydown(); + dispatchClick(150, 250); + await wait(20); // synthetic Enter is dispatched on a 1ms timeout + + v.assert.equal(capture.events.length, 1, 'one Enter keydown dispatched'); + const evt = capture.events[0]!; + v.assert.equal(evt.key, 'Enter'); + v.assert.equal(evt.keyCode, 13); + v.assert.equal(evt.which, 13); + v.assert.isUndefined( + Object.getOwnPropertyDescriptor(evt, 'key'), + 'constructor honored init members, so no own-property override', + ); + v.assert.isUndefined(Object.getOwnPropertyDescriptor(evt, 'keyCode')); + + capture.stop(); + dispose(); + }); + + v.test('legacy constructor: click still reports Enter/13 and reaches onEnter via the focus manager', async () => { + v.vi.stubGlobal('KeyboardEvent', LegacyKeyboardEvent); + + let app!: lng.ElementNode; + let btn!: lng.ElementNode; + const onEnter = v.vi.fn(); + + const dispose = renderer.render(() => { + const root = ( + + + + ); + useFocusManager(); + useMouse(app, 5); + return root; + }); + + await wait(20); + btn.setFocus(); + await wait(20); + v.assert.equal(lng.activeElement(), btn); + + const capture = captureKeydown(); + dispatchClick(150, 250); + await wait(20); + + v.assert.equal(capture.events.length, 1, 'one Enter keydown dispatched'); + const evt = capture.events[0]!; + v.assert.equal(evt.key, 'Enter', 'key forced onto instance'); + v.assert.equal(evt.keyCode, 13, 'keyCode forced onto instance'); + v.assert.equal(evt.which, 13, 'which forced onto instance'); + v.assert.equal( + onEnter.mock.calls.length, + 1, + 'focus manager mapped the synthetic Enter to onEnter', + ); + + capture.stop(); + dispose(); + }); + + v.test('legacy constructor: wheel scroll still reports ArrowUp/38 and ArrowDown/40', async () => { + v.vi.stubGlobal('KeyboardEvent', LegacyKeyboardEvent); + + let app!: lng.ElementNode; + + const dispose = renderer.render(() => { + const root = ( + + {}} /> + + ); + useMouse(app, 5); + return root; + }); + + await wait(20); + const capture = captureKeydown(); + window.dispatchEvent(new WheelEvent('wheel', { deltaY: 100 })); + await wait(300); // module-level scroll handler is throttled at 250ms + + v.assert.equal(capture.events.length, 1); + v.assert.equal(capture.events[0]!.key, 'ArrowDown'); + v.assert.equal(capture.events[0]!.keyCode, 40); + v.assert.equal(capture.events[0]!.which, 40); + + window.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 })); + await wait(300); + + v.assert.equal(capture.events.length, 2); + v.assert.equal(capture.events[1]!.key, 'ArrowUp'); + v.assert.equal(capture.events[1]!.keyCode, 38); + v.assert.equal(capture.events[1]!.which, 38); + + // let the trailing keyup timeout flush before other tests run + await wait(300); + capture.stop(); + dispose(); + }); +});