diff --git a/packages/devextreme/js/__internal/__tests__/draggable.test.ts b/packages/devextreme/js/__internal/__tests__/draggable.test.ts index 63c2bb37df8a..5bae6e5c0b72 100644 --- a/packages/devextreme/js/__internal/__tests__/draggable.test.ts +++ b/packages/devextreme/js/__internal/__tests__/draggable.test.ts @@ -5,70 +5,22 @@ import { expect, it, } from '@jest/globals'; -import eventsEngine from '@js/common/core/events/core/events_engine'; -import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; -import Draggable from '@js/ui/draggable'; - -const DRAGGABLE_ELEMENT_ID = 'draggable'; - -interface DraggableTest extends Draggable { - getDragInProgress: () => boolean; -} - -interface FiredEvent { - _cancelPreventDefault?: boolean; - isDefaultPrevented: () => boolean; -} - -const fire = (element: Element, type: string, x = 0, y = 0): FiredEvent => { - // @ts-expect-error -- Event is absent from the public eventsEngine type - const event: FiredEvent = eventsEngine.Event({ - type, - pageX: x, - pageY: y, - clientX: x, - clientY: y, - offset: { x, y }, - pointerType: 'touch', - pointers: type === 'dxpointerup' ? [] : [{ pointerId: 1 }], - pointerId: 1, - which: 1, - }); - - // @ts-expect-error -- trigger is absent from the public eventsEngine type - eventsEngine.trigger(element, event); - return event; -}; +import { createDraggable, disposeDraggable, fire } from './utils'; const swipe = (target: Element): void => { - fire(target, 'dxpointerdown', 0, 0); - fire(target, 'dxpointermove', 0, 20); - fire(target, 'dxpointermove', 0, 40); - fire(target, 'dxpointerup', 0, 40); -}; - -const appendElement = (): dxElementWrapper => $('
') - .attr('id', DRAGGABLE_ELEMENT_ID) - .css({ width: '100px', height: '40px' }) - .appendTo(document.body); - -const createDraggable = (options: Record = {}): DraggableTest => { - const $element = appendElement(); - - return new Draggable($element.get(0), { autoScroll: false, ...options }) as DraggableTest; + fire(target, 'dxpointerdown'); + fire(target, 'dxpointermove', { y: 20 }); + fire(target, 'dxpointermove', { y: 40 }); + fire(target, 'dxpointerup', { y: 40 }); }; beforeEach(() => { document.body.innerHTML = ''; }); -afterEach(() => { - const instance = Draggable.getInstance($(`#${DRAGGABLE_ELEMENT_ID}`).get(0)); - - instance?.dispose(); -}); +afterEach(disposeDraggable); describe('Draggable dragInProgress reset', () => { it('should reset dragInProgress when a drag is completed', () => { @@ -124,8 +76,8 @@ describe('Draggable preventDefault on a drag move', () => { const draggable = createDraggable(); const element = draggable.$element().get(0); - fire(element, 'dxpointerdown', 0, 0); - const event = fire(element, 'dxpointermove', 0, 20); + fire(element, 'dxpointerdown'); + const event = fire(element, 'dxpointermove', { y: 20 }); expect(event._cancelPreventDefault).not.toBe(true); expect(event.isDefaultPrevented()).toBe(true); diff --git a/packages/devextreme/js/__internal/__tests__/draggable_mac_ctrl_click.test.ts b/packages/devextreme/js/__internal/__tests__/draggable_mac_ctrl_click.test.ts new file mode 100644 index 000000000000..aa452603631c --- /dev/null +++ b/packages/devextreme/js/__internal/__tests__/draggable_mac_ctrl_click.test.ts @@ -0,0 +1,78 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import devices from '@ts/core/m_devices'; + +import { createDraggable, disposeDraggable, fire } from './utils'; + +jest.mock('@ts/core/m_devices', () => { + const originalModule: any = jest.requireActual('@ts/core/m_devices'); + const device = { mac: false, deviceType: 'desktop', platform: 'generic' }; + + return { + __esModule: true, + default: { + ...originalModule.default, + isSimulator: originalModule.default.isSimulator, + real: jest.fn().mockReturnValue(device), + current: jest.fn().mockReturnValue(device), + }, + }; +}); + +const setMac = (mac: boolean): void => { + (devices.real as jest.Mock).mockReturnValue({ mac, deviceType: 'desktop', platform: 'generic' }); +}; + +beforeEach(() => { + document.body.innerHTML = ''; + setMac(false); +}); + +afterEach(disposeDraggable); + +describe('Draggable macOS Ctrl+click (T1328053)', () => { + it('should not start a drag when a mouse move follows a macOS Ctrl+click', () => { + setMac(true); + const onDragStart = jest.fn(); + const draggable = createDraggable({ onDragStart }); + const element = draggable.$element().get(0); + + fire(element, 'dxpointerdown', { ctrlKey: true, pointerType: 'mouse' }); + fire(element, 'dxpointermove', { y: 40, pointerType: 'mouse' }); + + expect(onDragStart).not.toHaveBeenCalled(); + expect(draggable.getDragInProgress()).toBe(false); + }); + + it('should still start a drag for a regular (non-Ctrl) click on macOS', () => { + setMac(true); + const onDragStart = jest.fn(); + const draggable = createDraggable({ onDragStart }); + const element = draggable.$element().get(0); + + fire(element, 'dxpointerdown', { pointerType: 'mouse' }); + fire(element, 'dxpointermove', { y: 40, pointerType: 'mouse' }); + + expect(onDragStart).toHaveBeenCalledTimes(1); + expect(draggable.getDragInProgress()).toBe(true); + }); + + it('should still start a drag for a Ctrl+click on non-macOS platforms', () => { + setMac(false); + const onDragStart = jest.fn(); + const draggable = createDraggable({ onDragStart }); + const element = draggable.$element().get(0); + + fire(element, 'dxpointerdown', { ctrlKey: true, pointerType: 'mouse' }); + fire(element, 'dxpointermove', { y: 40, pointerType: 'mouse' }); + + expect(onDragStart).toHaveBeenCalledTimes(1); + expect(draggable.getDragInProgress()).toBe(true); + }); +}); diff --git a/packages/devextreme/js/__internal/__tests__/utils.ts b/packages/devextreme/js/__internal/__tests__/utils.ts new file mode 100644 index 000000000000..c543450609c3 --- /dev/null +++ b/packages/devextreme/js/__internal/__tests__/utils.ts @@ -0,0 +1,64 @@ +import eventsEngine from '@js/common/core/events/core/events_engine'; +import $ from '@js/core/renderer'; +import Draggable from '@js/ui/draggable'; + +export const DRAGGABLE_ELEMENT_ID = 'draggable'; + +export interface DraggableTest extends Draggable { + getDragInProgress: () => boolean; +} + +export interface FiredEvent { + _cancelPreventDefault?: boolean; + isDefaultPrevented: () => boolean; +} + +interface FireOptions { + x?: number; + y?: number; + ctrlKey?: boolean; + pointerType?: 'mouse' | 'touch'; +} + +export const fire = ( + element: Element, + type: string, + { + x = 0, y = 0, ctrlKey = false, pointerType = 'touch', + }: FireOptions = {}, +): FiredEvent => { + // @ts-expect-error -- Event is absent from the public eventsEngine type + const event: FiredEvent = eventsEngine.Event({ + type, + pageX: x, + pageY: y, + clientX: x, + clientY: y, + offset: { x, y }, + pointerType, + pointers: type === 'dxpointerup' ? [] : [{ pointerId: 1 }], + pointerId: 1, + which: 1, + ctrlKey, + }); + + // @ts-expect-error -- trigger is absent from the public eventsEngine type + eventsEngine.trigger(element, event); + + return event; +}; + +export const createDraggable = (options: Record = {}): DraggableTest => { + const $element = $('
') + .attr('id', DRAGGABLE_ELEMENT_ID) + .css({ width: '100px', height: '40px' }) + .appendTo(document.body); + + return new Draggable($element.get(0), { autoScroll: false, ...options }) as DraggableTest; +}; + +export const disposeDraggable = (): void => { + const instance = Draggable.getInstance($(`#${DRAGGABLE_ELEMENT_ID}`).get(0)); + + instance?.dispose(); +}; diff --git a/packages/devextreme/js/__internal/events/gesture/m_emitter.gesture.ts b/packages/devextreme/js/__internal/events/gesture/m_emitter.gesture.ts index 26a9be9ac708..628732697c4f 100644 --- a/packages/devextreme/js/__internal/events/gesture/m_emitter.gesture.ts +++ b/packages/devextreme/js/__internal/events/gesture/m_emitter.gesture.ts @@ -1,7 +1,7 @@ import Emitter from '@js/common/core/events/core/emitter'; import eventsEngine from '@js/common/core/events/core/events_engine'; import { - createEvent, eventData, eventDelta, isDxMouseWheelEvent, isTouchEvent, needSkipEvent, + createEvent, eventData, eventDelta, isDxMouseWheelEvent, isMouseEvent, isTouchEvent, needSkipEvent, } from '@js/common/core/events/utils/index'; import $ from '@js/core/renderer'; import callOnce from '@js/core/utils/call_once'; @@ -87,7 +87,12 @@ const GestureEmitter = Emitter.inherit({ }, start(e) { - if (e._needSkipEvent || needSkipEvent(e)) { + // T1328053: macOS Ctrl+click opens the system context menu. Kept out of needSkipEvent() + // because importing m_devices into that low-level events util forces `new Devices()` into + // early module init and breaks init order (resizeCallbacks stops firing). + const isMacContextMenuClick = isMouseEvent(e) && Boolean(e.ctrlKey) && devices.real().mac; + + if (e._needSkipEvent || needSkipEvent(e) || isMacContextMenuClick) { this._cancel(e); return; } diff --git a/packages/devextreme/js/__internal/events/utils/index.ts b/packages/devextreme/js/__internal/events/utils/index.ts index 7807a327c7d3..c9dcfc398408 100644 --- a/packages/devextreme/js/__internal/events/utils/index.ts +++ b/packages/devextreme/js/__internal/events/utils/index.ts @@ -57,6 +57,14 @@ const LEGACY_KEY_CODES = { 18: 'alt', }; +const MOUSE_BUTTON_MAP = { + left: 1, + middle: 2, + right: 3, + back: 4, + forward: 5, +}; + const EVENT_SOURCES_REGEX = { dx: /^dx/i, mouse: /(mouse|wheel)/i, @@ -141,7 +149,6 @@ export const hasTouches = (e) => { let skipEvents = false; export const forceSkipEvents = () => { skipEvents = true; }; export const stopEventsSkipping = () => { skipEvents = false; }; -// eslint-disable-next-line consistent-return export const needSkipEvent = (e) => { // TODO: for tests if (skipEvents) { @@ -151,7 +158,7 @@ export const needSkipEvent = (e) => { // TODO: this checking used in swipeable first move handler. is it correct? const { target } = e; const $target = $(target); - const isContentEditable = target?.isContentEditable || target?.hasAttribute('contenteditable'); + const isContentEditable: boolean = target?.isContentEditable || target?.hasAttribute('contenteditable'); const touchInEditable = $target.is('input, textarea, select') || isContentEditable; if (isDxMouseWheelEvent(e)) { @@ -167,12 +174,16 @@ export const needSkipEvent = (e) => { } if (isMouseEvent(e)) { - return touchInEditable || e.which > 1; // only left mouse button + const isNotLeftMouseButton = e.which > MOUSE_BUTTON_MAP.left; + + return touchInEditable || isNotLeftMouseButton; } if (isTouchEvent(e)) { return touchInEditable && focused($target); } + + return false; }; export const setEventFixMethod = (func) => { fixMethod = func; };