diff --git a/demos/aurelia/test/cypress/support/commands.ts b/demos/aurelia/test/cypress/support/commands.ts index 3be5a3685b..7f9b94442f 100644 --- a/demos/aurelia/test/cypress/support/commands.ts +++ b/demos/aurelia/test/cypress/support/commands.ts @@ -25,6 +25,7 @@ // -- This will overwrite an existing command -- import '@4tw/cypress-drag-drop'; import 'cypress-real-events'; +import './drag'; import { convertPosition } from './common'; declare global { @@ -32,19 +33,19 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; - convertPosition(viewport: string): Chainable | { x: string; y: string }>; + convertPosition(viewport: string): Chainable<{ x: string; y: string }>; getCell( row: number, col: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + ): Chainable>; getNthCell( row: number, nthCol: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + ): Chainable>; saveLocalStorage: () => void; restoreLocalStorage: () => void; getTransformValue(cssTransformMatrix: string, absoluteValue: boolean, transformType?: 'rotate' | 'scale'): Chainable; @@ -74,7 +75,7 @@ Cypress.Commands.add('getNthCell', (row, nthCol, viewport = 'topLeft', { parentS `${parentSelector} ${canvasSelectorX}${canvasSelectorY} [style="transform: translateY(${row * rowHeight}px);"] > .slick-cell:nth(${nthCol})` ); }); -const LOCAL_STORAGE_MEMORY: any = {}; +const LOCAL_STORAGE_MEMORY: Record = {}; Cypress.Commands.add('saveLocalStorage', () => { Object.keys(localStorage).forEach((key) => { diff --git a/demos/aurelia/test/cypress/support/drag.ts b/demos/aurelia/test/cypress/support/drag.ts index adc60cf3b5..5d0953047c 100644 --- a/demos/aurelia/test/cypress/support/drag.ts +++ b/demos/aurelia/test/cypress/support/drag.ts @@ -6,6 +6,7 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; + drag(target: string | HTMLElement | JQuery, options?: any): Chainable; dragOutside( viewport?: string, ms?: number, @@ -18,6 +19,53 @@ declare global { } } } + +Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, _options: any = {}) => { + return cy.wrap(subject as JQuery).then(($source) => { + const rawSourceElm = $source?.[0] as HTMLElement | undefined; + const rawTargetElm = + typeof target === 'string' + ? (Cypress.$(target).first()?.[0] as HTMLElement | undefined) + : ((target as JQuery)?.[0] as HTMLElement | undefined); + const sourceElm = + rawSourceElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawSourceElm; + const targetElm = + rawTargetElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawTargetElm; + + if (!sourceElm || !targetElm) { + return $source; + } + + const sourceRect = sourceElm.getBoundingClientRect(); + const targetRect = targetElm.getBoundingClientRect(); + const sourceX = Number.isFinite(sourceRect.left + sourceRect.width / 2) ? sourceRect.left + sourceRect.width / 2 : 0; + const sourceY = Number.isFinite(sourceRect.top + sourceRect.height / 2) ? sourceRect.top + sourceRect.height / 2 : 0; + const targetX = Number.isFinite(targetRect.left + targetRect.width / 2) ? targetRect.left + targetRect.width / 2 : 0; + const targetY = Number.isFinite(targetRect.top + targetRect.height / 2) ? targetRect.top + targetRect.height / 2 : 0; + + const dataTransfer = new DataTransfer(); + const createDragLikeEvent = (eventName: string, x: number, y: number): Event => { + const evt = new Event(eventName, { bubbles: true, cancelable: true }); + Object.defineProperty(evt, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(evt, 'clientX', { value: x }); + Object.defineProperty(evt, 'clientY', { value: y }); + Object.defineProperty(evt, 'pageX', { value: x }); + Object.defineProperty(evt, 'pageY', { value: y }); + Object.defineProperty(evt, 'screenX', { value: x }); + Object.defineProperty(evt, 'screenY', { value: y }); + return evt; + }; + + sourceElm.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY)); + targetElm.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('drop', targetX, targetY)); + sourceElm.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY)); + + return $source; + }); +}); + // @ts-ignore Cypress.Commands.add('dragStart', { prevSubject: true }, (subject: HTMLElement, { cellWidth = 90, cellHeight = 35 } = {}) => { return cy diff --git a/demos/react/test/cypress/support/commands.ts b/demos/react/test/cypress/support/commands.ts index 3be5a3685b..7f9b94442f 100644 --- a/demos/react/test/cypress/support/commands.ts +++ b/demos/react/test/cypress/support/commands.ts @@ -25,6 +25,7 @@ // -- This will overwrite an existing command -- import '@4tw/cypress-drag-drop'; import 'cypress-real-events'; +import './drag'; import { convertPosition } from './common'; declare global { @@ -32,19 +33,19 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; - convertPosition(viewport: string): Chainable | { x: string; y: string }>; + convertPosition(viewport: string): Chainable<{ x: string; y: string }>; getCell( row: number, col: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + ): Chainable>; getNthCell( row: number, nthCol: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + ): Chainable>; saveLocalStorage: () => void; restoreLocalStorage: () => void; getTransformValue(cssTransformMatrix: string, absoluteValue: boolean, transformType?: 'rotate' | 'scale'): Chainable; @@ -74,7 +75,7 @@ Cypress.Commands.add('getNthCell', (row, nthCol, viewport = 'topLeft', { parentS `${parentSelector} ${canvasSelectorX}${canvasSelectorY} [style="transform: translateY(${row * rowHeight}px);"] > .slick-cell:nth(${nthCol})` ); }); -const LOCAL_STORAGE_MEMORY: any = {}; +const LOCAL_STORAGE_MEMORY: Record = {}; Cypress.Commands.add('saveLocalStorage', () => { Object.keys(localStorage).forEach((key) => { diff --git a/demos/react/test/cypress/support/drag.ts b/demos/react/test/cypress/support/drag.ts index adc60cf3b5..5d0953047c 100644 --- a/demos/react/test/cypress/support/drag.ts +++ b/demos/react/test/cypress/support/drag.ts @@ -6,6 +6,7 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; + drag(target: string | HTMLElement | JQuery, options?: any): Chainable; dragOutside( viewport?: string, ms?: number, @@ -18,6 +19,53 @@ declare global { } } } + +Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, _options: any = {}) => { + return cy.wrap(subject as JQuery).then(($source) => { + const rawSourceElm = $source?.[0] as HTMLElement | undefined; + const rawTargetElm = + typeof target === 'string' + ? (Cypress.$(target).first()?.[0] as HTMLElement | undefined) + : ((target as JQuery)?.[0] as HTMLElement | undefined); + const sourceElm = + rawSourceElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawSourceElm; + const targetElm = + rawTargetElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawTargetElm; + + if (!sourceElm || !targetElm) { + return $source; + } + + const sourceRect = sourceElm.getBoundingClientRect(); + const targetRect = targetElm.getBoundingClientRect(); + const sourceX = Number.isFinite(sourceRect.left + sourceRect.width / 2) ? sourceRect.left + sourceRect.width / 2 : 0; + const sourceY = Number.isFinite(sourceRect.top + sourceRect.height / 2) ? sourceRect.top + sourceRect.height / 2 : 0; + const targetX = Number.isFinite(targetRect.left + targetRect.width / 2) ? targetRect.left + targetRect.width / 2 : 0; + const targetY = Number.isFinite(targetRect.top + targetRect.height / 2) ? targetRect.top + targetRect.height / 2 : 0; + + const dataTransfer = new DataTransfer(); + const createDragLikeEvent = (eventName: string, x: number, y: number): Event => { + const evt = new Event(eventName, { bubbles: true, cancelable: true }); + Object.defineProperty(evt, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(evt, 'clientX', { value: x }); + Object.defineProperty(evt, 'clientY', { value: y }); + Object.defineProperty(evt, 'pageX', { value: x }); + Object.defineProperty(evt, 'pageY', { value: y }); + Object.defineProperty(evt, 'screenX', { value: x }); + Object.defineProperty(evt, 'screenY', { value: y }); + return evt; + }; + + sourceElm.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY)); + targetElm.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('drop', targetX, targetY)); + sourceElm.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY)); + + return $source; + }); +}); + // @ts-ignore Cypress.Commands.add('dragStart', { prevSubject: true }, (subject: HTMLElement, { cellWidth = 90, cellHeight = 35 } = {}) => { return cy diff --git a/demos/vue/test/cypress/support/commands.ts b/demos/vue/test/cypress/support/commands.ts index 3be5a3685b..7f9b94442f 100644 --- a/demos/vue/test/cypress/support/commands.ts +++ b/demos/vue/test/cypress/support/commands.ts @@ -25,6 +25,7 @@ // -- This will overwrite an existing command -- import '@4tw/cypress-drag-drop'; import 'cypress-real-events'; +import './drag'; import { convertPosition } from './common'; declare global { @@ -32,19 +33,19 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; - convertPosition(viewport: string): Chainable | { x: string; y: string }>; + convertPosition(viewport: string): Chainable<{ x: string; y: string }>; getCell( row: number, col: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + ): Chainable>; getNthCell( row: number, nthCol: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + ): Chainable>; saveLocalStorage: () => void; restoreLocalStorage: () => void; getTransformValue(cssTransformMatrix: string, absoluteValue: boolean, transformType?: 'rotate' | 'scale'): Chainable; @@ -74,7 +75,7 @@ Cypress.Commands.add('getNthCell', (row, nthCol, viewport = 'topLeft', { parentS `${parentSelector} ${canvasSelectorX}${canvasSelectorY} [style="transform: translateY(${row * rowHeight}px);"] > .slick-cell:nth(${nthCol})` ); }); -const LOCAL_STORAGE_MEMORY: any = {}; +const LOCAL_STORAGE_MEMORY: Record = {}; Cypress.Commands.add('saveLocalStorage', () => { Object.keys(localStorage).forEach((key) => { diff --git a/demos/vue/test/cypress/support/drag.ts b/demos/vue/test/cypress/support/drag.ts index adc60cf3b5..5d0953047c 100644 --- a/demos/vue/test/cypress/support/drag.ts +++ b/demos/vue/test/cypress/support/drag.ts @@ -6,6 +6,7 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; + drag(target: string | HTMLElement | JQuery, options?: any): Chainable; dragOutside( viewport?: string, ms?: number, @@ -18,6 +19,53 @@ declare global { } } } + +Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, _options: any = {}) => { + return cy.wrap(subject as JQuery).then(($source) => { + const rawSourceElm = $source?.[0] as HTMLElement | undefined; + const rawTargetElm = + typeof target === 'string' + ? (Cypress.$(target).first()?.[0] as HTMLElement | undefined) + : ((target as JQuery)?.[0] as HTMLElement | undefined); + const sourceElm = + rawSourceElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawSourceElm; + const targetElm = + rawTargetElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawTargetElm; + + if (!sourceElm || !targetElm) { + return $source; + } + + const sourceRect = sourceElm.getBoundingClientRect(); + const targetRect = targetElm.getBoundingClientRect(); + const sourceX = Number.isFinite(sourceRect.left + sourceRect.width / 2) ? sourceRect.left + sourceRect.width / 2 : 0; + const sourceY = Number.isFinite(sourceRect.top + sourceRect.height / 2) ? sourceRect.top + sourceRect.height / 2 : 0; + const targetX = Number.isFinite(targetRect.left + targetRect.width / 2) ? targetRect.left + targetRect.width / 2 : 0; + const targetY = Number.isFinite(targetRect.top + targetRect.height / 2) ? targetRect.top + targetRect.height / 2 : 0; + + const dataTransfer = new DataTransfer(); + const createDragLikeEvent = (eventName: string, x: number, y: number): Event => { + const evt = new Event(eventName, { bubbles: true, cancelable: true }); + Object.defineProperty(evt, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(evt, 'clientX', { value: x }); + Object.defineProperty(evt, 'clientY', { value: y }); + Object.defineProperty(evt, 'pageX', { value: x }); + Object.defineProperty(evt, 'pageY', { value: y }); + Object.defineProperty(evt, 'screenX', { value: x }); + Object.defineProperty(evt, 'screenY', { value: y }); + return evt; + }; + + sourceElm.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY)); + targetElm.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('drop', targetX, targetY)); + sourceElm.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY)); + + return $source; + }); +}); + // @ts-ignore Cypress.Commands.add('dragStart', { prevSubject: true }, (subject: HTMLElement, { cellWidth = 90, cellHeight = 35 } = {}) => { return cy diff --git a/frameworks/angular-slickgrid/test/cypress/support/commands.ts b/frameworks/angular-slickgrid/test/cypress/support/commands.ts index 5b395aa95c..7f9b94442f 100644 --- a/frameworks/angular-slickgrid/test/cypress/support/commands.ts +++ b/frameworks/angular-slickgrid/test/cypress/support/commands.ts @@ -1,3 +1,4 @@ +/* eslint-disable n/file-extension-in-import */ // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite @@ -24,6 +25,7 @@ // -- This will overwrite an existing command -- import '@4tw/cypress-drag-drop'; import 'cypress-real-events'; +import './drag'; import { convertPosition } from './common'; declare global { @@ -31,19 +33,19 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; - convertPosition(viewport: string): Chainable | { x: string; y: string }>; + convertPosition(viewport: string): Chainable<{ x: string; y: string }>; getCell( row: number, col: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + ): Chainable>; getNthCell( row: number, nthCol: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + ): Chainable>; saveLocalStorage: () => void; restoreLocalStorage: () => void; getTransformValue(cssTransformMatrix: string, absoluteValue: boolean, transformType?: 'rotate' | 'scale'): Chainable; @@ -51,20 +53,6 @@ declare global { } } -const LOCAL_STORAGE_MEMORY: any = {}; - -Cypress.Commands.add('saveLocalStorage', () => { - Object.keys(localStorage).forEach((key) => { - LOCAL_STORAGE_MEMORY[key] = localStorage[key]; - }); -}); - -Cypress.Commands.add('restoreLocalStorage', () => { - Object.keys(LOCAL_STORAGE_MEMORY).forEach((key) => { - localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]); - }); -}); - // convert position like 'topLeft' to the object { x: 'left|right', y: 'top|bottom' } Cypress.Commands.add('convertPosition', (viewport = 'topLeft') => cy.wrap(convertPosition(viewport))); @@ -87,6 +75,19 @@ Cypress.Commands.add('getNthCell', (row, nthCol, viewport = 'topLeft', { parentS `${parentSelector} ${canvasSelectorX}${canvasSelectorY} [style="transform: translateY(${row * rowHeight}px);"] > .slick-cell:nth(${nthCol})` ); }); +const LOCAL_STORAGE_MEMORY: Record = {}; + +Cypress.Commands.add('saveLocalStorage', () => { + Object.keys(localStorage).forEach((key) => { + LOCAL_STORAGE_MEMORY[key] = localStorage[key]; + }); +}); + +Cypress.Commands.add('restoreLocalStorage', () => { + Object.keys(LOCAL_STORAGE_MEMORY).forEach((key) => { + localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]); + }); +}); Cypress.Commands.add( 'getTransformValue', diff --git a/frameworks/angular-slickgrid/test/cypress/support/drag.ts b/frameworks/angular-slickgrid/test/cypress/support/drag.ts index 074f5e9360..7b4b8d7706 100644 --- a/frameworks/angular-slickgrid/test/cypress/support/drag.ts +++ b/frameworks/angular-slickgrid/test/cypress/support/drag.ts @@ -5,6 +5,7 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; + drag(target: string | HTMLElement | JQuery, options?: any): Chainable; dragOutside( viewport?: string, ms?: number, @@ -17,6 +18,53 @@ declare global { } } } + +Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, _options: any = {}) => { + return cy.wrap(subject as JQuery).then(($source) => { + const rawSourceElm = $source?.[0] as HTMLElement | undefined; + const rawTargetElm = + typeof target === 'string' + ? (Cypress.$(target).first()?.[0] as HTMLElement | undefined) + : ((target as JQuery)?.[0] as HTMLElement | undefined); + const sourceElm = + rawSourceElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawSourceElm; + const targetElm = + rawTargetElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawTargetElm; + + if (!sourceElm || !targetElm) { + return $source; + } + + const sourceRect = sourceElm.getBoundingClientRect(); + const targetRect = targetElm.getBoundingClientRect(); + const sourceX = Number.isFinite(sourceRect.left + sourceRect.width / 2) ? sourceRect.left + sourceRect.width / 2 : 0; + const sourceY = Number.isFinite(sourceRect.top + sourceRect.height / 2) ? sourceRect.top + sourceRect.height / 2 : 0; + const targetX = Number.isFinite(targetRect.left + targetRect.width / 2) ? targetRect.left + targetRect.width / 2 : 0; + const targetY = Number.isFinite(targetRect.top + targetRect.height / 2) ? targetRect.top + targetRect.height / 2 : 0; + + const dataTransfer = new DataTransfer(); + const createDragLikeEvent = (eventName: string, x: number, y: number): Event => { + const evt = new Event(eventName, { bubbles: true, cancelable: true }); + Object.defineProperty(evt, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(evt, 'clientX', { value: x }); + Object.defineProperty(evt, 'clientY', { value: y }); + Object.defineProperty(evt, 'pageX', { value: x }); + Object.defineProperty(evt, 'pageY', { value: y }); + Object.defineProperty(evt, 'screenX', { value: x }); + Object.defineProperty(evt, 'screenY', { value: y }); + return evt; + }; + + sourceElm.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY)); + targetElm.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('drop', targetX, targetY)); + sourceElm.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY)); + + return $source; + }); +}); + // @ts-ignore Cypress.Commands.add('dragStart', { prevSubject: true }, (subject: HTMLElement, { cellWidth = 90, cellHeight = 35 } = {}) => { return cy diff --git a/frameworks/angular-slickgrid/types/sortablejs.d.ts b/frameworks/angular-slickgrid/types/sortablejs.d.ts deleted file mode 100644 index 28d42efe12..0000000000 --- a/frameworks/angular-slickgrid/types/sortablejs.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'sortablejs/modular/sortable.core.esm.js' { - import Sortable from 'sortablejs'; - export default Sortable; -} diff --git a/frameworks/slickgrid-vue/vite.config.mts b/frameworks/slickgrid-vue/vite.config.mts index 35e5523632..25f54afa32 100644 --- a/frameworks/slickgrid-vue/vite.config.mts +++ b/frameworks/slickgrid-vue/vite.config.mts @@ -35,7 +35,6 @@ export default defineConfig({ 'dequal', 'i18next', 'i18next-vue', - 'sortablejs', 'vue', ], output: { diff --git a/package.json b/package.json index 9970212de1..c82688ea81 100644 --- a/package.json +++ b/package.json @@ -108,8 +108,7 @@ "postversion": "pnpm angular:replace-workspace" }, "comments": { - "new-version": "To create a new version with Lerna-Lite, simply run the following script (1) 'roll-new-release'.", - "devDependencies": "The dev deps 'slickgrid' and 'sortablejs' are simply installed for Vitest unit tests." + "new-version": "To create a new version with Lerna-Lite, simply run the following script (1) 'roll-new-release'." }, "engines": { "node": "^22.17.0 || >=24.0.0", @@ -144,7 +143,6 @@ "prettier": "^3.9.4", "remove-glob": "catalog:", "rxjs": "catalog:", - "sortablejs": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, diff --git a/packages/common/package.json b/packages/common/package.json index d29c710d6b..3f1bc7c1eb 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -66,12 +66,10 @@ "@slickgrid-universal/binding": "workspace:*", "@slickgrid-universal/event-pub-sub": "workspace:*", "@slickgrid-universal/utils": "workspace:*", - "@types/sortablejs": "catalog:", "@types/trusted-types": "^2.0.7", "autocompleter": "^9.3.2", "dequal": "catalog:", "multiple-select-vanilla": "catalog:", - "sortablejs": "catalog:", "vanilla-calendar-pro": "^3.1.0" }, "devDependencies": { diff --git a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts new file mode 100644 index 0000000000..1722658723 --- /dev/null +++ b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts @@ -0,0 +1,1862 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setupColumnReorderDrag, setupDropzonePillDrag } from '../slickColumnReorderDrag.js'; + +describe('slickColumnReorderDrag', () => { + const createHeaderColumn = (id: string, extraClass = '') => { + const elm = document.createElement('div'); + elm.className = `slick-header-column ${extraClass}`.trim(); + elm.dataset.id = id; + elm.textContent = id; + return elm; + }; + + const createDragEvent = (type: string, props: Partial> = {}) => { + const evt = new Event(type, { bubbles: true, cancelable: true }) as DragEvent; + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(evt, key, { value, configurable: true, writable: true }); + } + return evt; + }; + + const createMouseEvent = (type: string, props: Partial> = {}) => { + const evt = new MouseEvent(type, { bubbles: true, cancelable: true }); + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(evt, key, { value, configurable: true, writable: true }); + } + return evt; + }; + + const createTouchEvent = (type: string, props: Partial> = {}) => { + const evt = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent; + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(evt, key, { value, configurable: true, writable: true }); + } + return evt; + }; + + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('should initialize draggable headers and clear draggable flags on destroy', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const orderable = createHeaderColumn('firstName'); + const unorderable = createHeaderColumn('lastName', 'unorderable'); + headerLeft.append(orderable, unorderable); + + const instance = setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + unorderableColumnCssClass: 'unorderable', + onDragEnd: vi.fn(), + }); + + expect(orderable.draggable).toBe(true); + expect(unorderable.draggable).toBe(false); + + instance.destroy(); + + expect(orderable.draggable).toBe(false); + expect(unorderable.draggable).toBe(false); + }); + + it('should prevent dragstart when event target is not a header column', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const onDragEnd = vi.fn(); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + }); + + const dragStartEvt = createDragEvent('dragstart'); + const preventDefaultSpy = vi.spyOn(dragStartEvt, 'preventDefault'); + headerLeft.dispatchEvent(dragStartEvt); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(onDragEnd).not.toHaveBeenCalled(); + }); + + it('should prevent native dragstart from header menu button target', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const headerMenuBtn = document.createElement('button'); + headerMenuBtn.className = 'slick-header-menu-button'; + firstName.append(headerMenuBtn); + headerLeft.append(firstName); + + const onDragStart = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + dragStartFilter: '.slick-header-menu-button', + onDragStart, + onDragEnd: vi.fn(), + }); + + const dragStartEvt = createDragEvent('dragstart', { + target: headerMenuBtn, + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }); + const preventDefaultSpy = vi.spyOn(dragStartEvt, 'preventDefault'); + firstName.dispatchEvent(dragStartEvt); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(onDragStart).not.toHaveBeenCalled(); + }); + + it('should prevent native dragstart when dragStartFilter selector matches target', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const dragHandle = document.createElement('span'); + dragHandle.className = 'no-reorder-handle'; + firstName.append(dragHandle); + headerLeft.append(firstName); + + const onDragStart = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + dragStartFilter: '.no-reorder-handle', + onDragStart, + onDragEnd: vi.fn(), + }); + + const dragStartEvt = createDragEvent('dragstart', { + target: dragHandle, + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }); + const preventDefaultSpy = vi.spyOn(dragStartEvt, 'preventDefault'); + firstName.dispatchEvent(dragStartEvt); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(onDragStart).not.toHaveBeenCalled(); + }); + + it('should prevent native dragstart when dragStartFilter function returns true', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const onDragStart = vi.fn(); + const dragStartFilter = vi.fn(() => true); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + dragStartFilter, + onDragStart, + onDragEnd: vi.fn(), + }); + + const dragStartEvt = createDragEvent('dragstart', { + target: firstName, + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }); + const preventDefaultSpy = vi.spyOn(dragStartEvt, 'preventDefault'); + firstName.dispatchEvent(dragStartEvt); + + expect(dragStartFilter).toHaveBeenCalledWith(firstName, dragStartEvt); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(onDragStart).not.toHaveBeenCalled(); + }); + + it('should prevent default on dragover and skip when drag was never started', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const target = createHeaderColumn('age'); + headerLeft.append(target); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + const dragOverEvt = createDragEvent('dragover', { clientX: 120 }); + const preventDefaultSpy = vi.spyOn(dragOverEvt, 'preventDefault'); + target.dispatchEvent(dragOverEvt); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(headerLeft.firstElementChild).toBe(target); + }); + + it('should move dragged header before target when cursor is in target left half', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + const age = createHeaderColumn('age'); + headerLeft.append(firstName, lastName, age); + + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + }); + + const dragStartEvt = createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + offsetX: 8, + offsetY: 4, + }); + firstName.dispatchEvent(dragStartEvt); + + vi.spyOn(lastName, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + + const dragOverEvt = createDragEvent('dragover', { clientX: 110 }); // left side of lastName + lastName.dispatchEvent(dragOverEvt); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName', 'age']); + + const dragEndEvt = createDragEvent('dragend'); + firstName.dispatchEvent(dragEndEvt); + expect(onDragEnd).toHaveBeenCalledWith(['firstName', 'lastName', 'age']); + }); + + it('should ignore dragover when target is the same dragged header element', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + headerLeft.append(firstName, lastName); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + const dragStartEvt = createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + offsetX: 5, + offsetY: 3, + }); + firstName.dispatchEvent(dragStartEvt); + + const dragOverEvt = createDragEvent('dragover', { clientX: 10 }); + firstName.dispatchEvent(dragOverEvt); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName']); + }); + + it('should move dragged header after target when cursor is in target right half', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + const age = createHeaderColumn('age'); + headerLeft.append(firstName, lastName, age); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + const dragStartEvt = createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + offsetX: 8, + offsetY: 4, + }); + firstName.dispatchEvent(dragStartEvt); + + vi.spyOn(lastName, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + + const dragOverEvt = createDragEvent('dragover', { clientX: 139 }); // right side of lastName + lastName.dispatchEvent(dragOverEvt); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['lastName', 'firstName', 'age']); + }); + + it('should not move dragged header into a preheader-style container', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + const preHeaderGroup = createHeaderColumn('preHeaderGroup'); + const preHeaderContainer = document.createElement('div'); + preHeaderContainer.append(preHeaderGroup); + headerLeft.append(firstName, lastName); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + const dragStartEvt = createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + offsetX: 8, + offsetY: 4, + }); + firstName.dispatchEvent(dragStartEvt); + + vi.spyOn(preHeaderGroup, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + + const dragOverEvt = createDragEvent('dragover', { clientX: 110 }); + preHeaderGroup.dispatchEvent(dragOverEvt); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName']); + expect(preHeaderContainer.contains(firstName)).toBe(false); + }); + + it('should call setDragImage with rect-relative coords (Firefox/Linux ghost fix) on dragstart', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + const setDragImageSpy = vi.fn(); + // Simulate the column header at left=50, top=10 in the viewport + vi.spyOn(firstName, 'getBoundingClientRect').mockReturnValue({ + left: 50, + top: 10, + right: 130, + bottom: 30, + width: 80, + height: 20, + x: 50, + y: 10, + toJSON: () => ({}), + } as DOMRect); + + const dragStartEvt = createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: setDragImageSpy }, + clientX: 60, // 10px inside the column header + clientY: 15, // 5px inside the column header + }); + firstName.dispatchEvent(dragStartEvt); + + // The ghost should be anchored at (clientX - rect.left, clientY - rect.top) = (10, 5) + // NOT at e.offsetX / e.offsetY which would be relative to whichever child element was clicked + expect(setDragImageSpy).toHaveBeenCalledWith(firstName, 10, 5); + }); + + it('should notify onDrop instead of onDragEnd when dropped onto a dropzone', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + document.body.append(dropzone); + headerLeft.append(firstName); + + const onDragEnd = vi.fn(); + const onDrop = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + onDrop, + }); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => dropzone as Element), + }); + + const dragStartEvt = createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }); + firstName.dispatchEvent(dragStartEvt); + + const dragEndEvt = createDragEvent('dragend', { clientX: 20, clientY: 20 }); + firstName.dispatchEvent(dragEndEvt); + + expect(onDrop).toHaveBeenCalledWith(firstName, dragEndEvt, 'firstName'); + expect(onDragEnd).not.toHaveBeenCalled(); + }); + + it('should reorder headers with Firefox/Linux mouse fallback and call onDragEnd', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + headerLeft.append(firstName, lastName); + + vi.spyOn(lastName, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => lastName as Element), + }); + + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 139, clientY: 10 })); + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 139, clientY: 10 })); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['lastName', 'firstName']); + expect(onDragEnd).toHaveBeenCalledWith(['lastName', 'firstName']); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should call onDrop on Firefox/Linux fallback mouseup when dropped over a dropzone', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + document.body.append(dropzone); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => dropzone as Element), + }); + + const onDragEnd = vi.fn(); + const onDrop = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + onDrop, + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 20, clientY: 20 })); + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 20, clientY: 20 })); + + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDragEnd).not.toHaveBeenCalled(); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should reorder headers and call onDragEnd with touch fallback', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + headerLeft.append(firstName, lastName); + + vi.spyOn(lastName, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => lastName as Element), + }); + + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + }); + + firstName.dispatchEvent( + createTouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 10, pageX: 10 }], + changedTouches: [{ clientX: 10, clientY: 10, pageX: 10 }], + target: firstName, + }) + ); + document.dispatchEvent( + createTouchEvent('touchmove', { + touches: [{ clientX: 139, clientY: 10, pageX: 139 }], + changedTouches: [{ clientX: 139, clientY: 10, pageX: 139 }], + }) + ); + document.dispatchEvent( + createTouchEvent('touchend', { + touches: [], + changedTouches: [{ clientX: 139, clientY: 10, pageX: 139 }], + }) + ); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['lastName', 'firstName']); + expect(onDragEnd).toHaveBeenCalledWith(['lastName', 'firstName']); + }); + + it('should call onDrop on touch fallback when dropped over a dropzone', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + document.body.append(dropzone); + headerLeft.append(firstName); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => dropzone as Element), + }); + + const onDragEnd = vi.fn(); + const onDrop = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + onDrop, + }); + + firstName.dispatchEvent( + createTouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 10, pageX: 10 }], + changedTouches: [{ clientX: 10, clientY: 10, pageX: 10 }], + target: firstName, + }) + ); + document.dispatchEvent( + createTouchEvent('touchmove', { + touches: [{ clientX: 20, clientY: 20, pageX: 20 }], + changedTouches: [{ clientX: 20, clientY: 20, pageX: 20 }], + }) + ); + document.dispatchEvent( + createTouchEvent('touchend', { + touches: [], + changedTouches: [{ clientX: 20, clientY: 20, pageX: 20 }], + }) + ); + + expect(onDrop).toHaveBeenCalledWith(firstName, expect.any(Event), 'firstName'); + expect(onDragEnd).not.toHaveBeenCalled(); + }); + + // ── Deviation 1: frozen boundary stays uncrossable ──────────────────────── + + it('should not allow dragging a column from headerLeft into headerRight (frozen boundary uncrossable)', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); // in left (frozen) pane + const start = createHeaderColumn('start'); // in right pane + headerLeft.append(firstName); + headerRight.append(start); + + vi.spyOn(start, 'getBoundingClientRect').mockReturnValue({ + left: 200, + width: 80, + right: 280, + top: 0, + bottom: 20, + x: 200, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + // elementFromPoint returns the right-pane column + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => start as Element), + }); + + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => true, + onDragEnd, + }); + + firstName.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }) + ); + // dragover over the right-pane column — should NOT trigger a cross-boundary reorder + headerRight.dispatchEvent(createDragEvent('dragover', { clientX: 240, clientY: 10, target: start })); + + // firstName must still be in headerLeft, start must still be in headerRight + expect(headerLeft.children[0]).toBe(firstName); + expect(headerRight.children[0]).toBe(start); + + firstName.dispatchEvent(createDragEvent('dragend', { clientX: 240, clientY: 10 })); + // Same order (no cross-boundary reorder occurred) — onDragEnd receives all column IDs + // from both headers; setColumns is a no-op at the grid level (same order). + expect(onDragEnd).toHaveBeenCalledWith(['firstName', 'start']); + }); + + it('should not allow dragging a column from headerRight into headerLeft (frozen boundary uncrossable)', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); // in left (frozen) pane + const start = createHeaderColumn('start'); // in right pane + headerLeft.append(firstName); + headerRight.append(start); + + vi.spyOn(firstName, 'getBoundingClientRect').mockReturnValue({ + left: 0, + width: 100, + right: 100, + top: 0, + bottom: 20, + x: 0, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => firstName as Element), + }); + + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => true, + onDragEnd, + }); + + start.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 240, + clientY: 10, + }) + ); + // dragover over the left-pane column — should NOT trigger a cross-boundary reorder + headerLeft.dispatchEvent(createDragEvent('dragover', { clientX: 50, clientY: 10, target: firstName })); + + expect(headerLeft.children[0]).toBe(firstName); + expect(headerRight.children[0]).toBe(start); + + start.dispatchEvent(createDragEvent('dragend', { clientX: 50, clientY: 10 })); + expect(onDragEnd).toHaveBeenCalledWith(['firstName', 'start']); + }); + + // ── Deviation 2: finalize on `drop` as well as `dragend` ───────────────── + + it('should finalize the reorder on drop (no subsequent dragend) and call onDragEnd', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + headerLeft.append(firstName, lastName); + + vi.spyOn(lastName, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 20, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => lastName as Element), + }); + + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + }); + + firstName.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }) + ); + // simulate live DOM reorder via dragover + headerLeft.dispatchEvent(createDragEvent('dragover', { clientX: 139, clientY: 10, target: lastName })); + + // drop fires on the header (no dragend follows — e.g. @4tw/cypress-drag-drop behaviour) + const dropEvt = createDragEvent('drop', { clientX: 139, clientY: 10 }); + headerLeft.dispatchEvent(dropEvt); + + expect(onDragEnd).toHaveBeenCalledTimes(1); + expect(onDragEnd).toHaveBeenCalledWith(expect.arrayContaining(['lastName', 'firstName'])); + }); + + it('should not double-finalize when both drop and dragend fire (real browser sequence)', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + headerLeft.append(firstName, lastName); + + vi.spyOn(lastName, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 20, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => lastName as Element), + }); + + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + }); + + firstName.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }) + ); + headerLeft.dispatchEvent(createDragEvent('dragover', { clientX: 139, clientY: 10, target: lastName })); + + // real browser fires drop then dragend + headerLeft.dispatchEvent(createDragEvent('drop', { clientX: 139, clientY: 10 })); + firstName.dispatchEvent(createDragEvent('dragend', { clientX: 139, clientY: 10 })); + + // onDragEnd must be called exactly once despite both events firing + expect(onDragEnd).toHaveBeenCalledTimes(1); + }); + + it('should ignore Firefox/Linux mousedown drag start from header menu button', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const headerMenuBtn = document.createElement('button'); + headerMenuBtn.className = 'slick-header-menu-button'; + firstName.append(headerMenuBtn); + headerLeft.append(firstName); + + const onDragStart = vi.fn(); + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + dragStartFilter: '.slick-header-menu-button', + onDragStart, + onDragEnd, + }); + + headerMenuBtn.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: headerMenuBtn })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 140, clientY: 10, pageX: 140 })); + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 140, clientY: 10 })); + + expect(onDragStart).not.toHaveBeenCalled(); + expect(onDragEnd).not.toHaveBeenCalled(); + expect(firstName.classList.contains('slick-header-column-active')).toBe(false); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should ignore touch drag start from header menu button', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const headerMenuBtn = document.createElement('button'); + headerMenuBtn.className = 'slick-header-menu-button'; + firstName.append(headerMenuBtn); + headerLeft.append(firstName); + + const onDragStart = vi.fn(); + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + dragStartFilter: '.slick-header-menu-button', + onDragStart, + onDragEnd, + }); + + headerMenuBtn.dispatchEvent( + createTouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 10, pageX: 10 }], + changedTouches: [{ clientX: 10, clientY: 10, pageX: 10 }], + target: headerMenuBtn, + }) + ); + document.dispatchEvent( + createTouchEvent('touchmove', { + touches: [{ clientX: 140, clientY: 10, pageX: 140 }], + changedTouches: [{ clientX: 140, clientY: 10, pageX: 140 }], + }) + ); + document.dispatchEvent( + createTouchEvent('touchend', { + touches: [], + changedTouches: [{ clientX: 140, clientY: 10, pageX: 140 }], + }) + ); + + expect(onDragStart).not.toHaveBeenCalled(); + expect(onDragEnd).not.toHaveBeenCalled(); + expect(firstName.classList.contains('slick-header-column-active')).toBe(false); + }); + + it('should keep working when elementFromPoint throws in dragend and still call onDragEnd', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const onDragEnd = vi.fn(); + const instance = setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + }); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => { + throw new Error('boom'); + }), + }); + + firstName.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }) + ); + firstName.dispatchEvent(createDragEvent('dragend', { clientX: 20, clientY: 20 })); + + expect(onDragEnd).toHaveBeenCalledWith(['firstName']); + + instance.destroy(); + }); + + it('should return early on dragover when pointer is over a dropzone and restore dragged header parent', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + headerLeft.append(firstName, lastName); + const foreignParent = document.createElement('div'); + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + document.body.append(dropzone, foreignParent); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + firstName.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }) + ); + + foreignParent.append(firstName); + + const dropzoneOverEvent = createDragEvent('dragover', { clientX: 10, clientY: 10, target: dropzone }); + headerLeft.dispatchEvent(dropzoneOverEvent); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName']); + }); + + it('should handle document-level dropzone enter/leave/hover listeners during an active drag', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + headerLeft.append(firstName, lastName); + + const foreignParent = document.createElement('div'); + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + document.body.append(dropzone, foreignParent); + + const onDrop = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + onDrop, + }); + + firstName.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }) + ); + + // simulate dragged element being moved outside headers before document-level listeners fire + foreignParent.append(firstName); + + const dragEnterEvt = createDragEvent('dragenter', { target: dropzone }); + document.dispatchEvent(dragEnterEvt); + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName']); + + // dragleave should clear dropzone active state and avoid onDrop + const dragLeaveEvt = createDragEvent('dragleave', { target: dropzone }); + document.dispatchEvent(dragLeaveEvt); + + firstName.dispatchEvent(createDragEvent('dragend', { clientX: 20, clientY: 20 })); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it('should keep dropzone active on dragleave when relatedTarget is null but pointer is still over dropzone', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + const dropzoneChild = document.createElement('div'); + dropzone.append(dropzoneChild); + document.body.append(dropzone); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => dropzoneChild as Element), + }); + + const onDrop = vi.fn(); + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd, + onDrop, + }); + + firstName.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }) + ); + + // mark dropzone active first + document.dispatchEvent(createDragEvent('dragenter', { target: dropzone })); + + // dragleave with null relatedTarget but elementFromPoint inside dropzone should keep it active + const dragLeaveEvt = createDragEvent('dragleave', { target: dropzone, relatedTarget: null, clientX: 25, clientY: 25 }); + document.dispatchEvent(dragLeaveEvt); + + firstName.dispatchEvent(createDragEvent('dragend', { clientX: 25, clientY: 25 })); + + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDragEnd).not.toHaveBeenCalled(); + }); + + it('should return early on dragover when target parent is not a header container', () => { + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + const lastName = createHeaderColumn('lastName'); + const preHeaderGroup = createHeaderColumn('preHeaderGroup'); + const preHeaderContainer = document.createElement('div'); + preHeaderContainer.append(preHeaderGroup); + headerLeft.append(firstName, lastName); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + firstName.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + }) + ); + + // listener is on header containers, so dispatch from headerLeft with a foreign target + headerLeft.dispatchEvent(createDragEvent('dragover', { target: preHeaderGroup, clientX: 110, clientY: 10 })); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName']); + }); + + it('should return early in fallback mousemove when target header parent is not a header container', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const externalParent = document.createElement('div'); + const externalHeader = createHeaderColumn('externalHeader'); + externalParent.append(externalHeader); + document.body.append(externalParent); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => externalHeader as Element), + }); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 120, clientY: 10 })); + + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName']); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should toggle dropzone hover class during Firefox/Linux fallback mousemove', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const nonDropzoneElm = document.createElement('div'); + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + const dropzoneChild = document.createElement('div'); + dropzone.append(dropzoneChild); + document.body.append(nonDropzoneElm, dropzone); + + const elementFromPointMock = vi.fn((x: number) => (x === 40 ? (dropzoneChild as Element) : (nonDropzoneElm as Element))); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 40, clientY: 10 })); + expect(dropzone.classList.contains('slick-dropzone-hover')).toBe(true); + + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 80, clientY: 10 })); + expect(dropzone.classList.contains('slick-dropzone-hover')).toBe(false); + + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 80, clientY: 10 })); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should detect dropzone from elementFromPoint on fallback mouseup when dropzone was not previously active', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const nonDropzoneElm = document.createElement('div'); + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + document.body.append(nonDropzoneElm, dropzone); + + const elementFromPointMock = vi.fn((x: number) => (x === 30 ? (dropzone as Element) : (nonDropzoneElm as Element))); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + + const onDrop = vi.fn(); + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDrop, + onDragEnd, + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 20, clientY: 20 })); + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 30, clientY: 20 })); + + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDragEnd).not.toHaveBeenCalled(); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should keep fallback column drag uncommitted when mousemove stays below threshold', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const elementFromPointMock = vi.fn(() => firstName); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + + const onDragStart = vi.fn(); + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragStart, + onDragEnd, + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + const moveEvt = createMouseEvent('mousemove', { clientX: 12, clientY: 12, pageX: 12 }); + const preventDefaultSpy = vi.spyOn(moveEvt, 'preventDefault'); + document.dispatchEvent(moveEvt); + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 12, clientY: 12 })); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(elementFromPointMock).toHaveBeenCalledTimes(1); + expect(onDragStart).not.toHaveBeenCalled(); + expect(onDragEnd).not.toHaveBeenCalled(); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should set droppedOnDropzone during fallback mouseup from elementFromPoint detection path', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const nonDropzoneElm = document.createElement('div'); + const dropzone = document.createElement('div'); + dropzone.className = 'slick-dropzone'; + document.body.append(nonDropzoneElm, dropzone); + + let pointCallCount = 0; + const elementFromPointMock = vi.fn(() => { + pointCallCount += 1; + return pointCallCount === 1 ? (nonDropzoneElm as Element) : (dropzone as Element); + }); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + + const onDrop = vi.fn(); + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDrop, + onDragEnd, + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + // 1st call to elementFromPoint from fallback mousemove => non-dropzone + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 20, clientY: 20 })); + // 2nd call from fallback mouseup => dropzone (hits droppedOnDropzone = true path) + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 30, clientY: 30 })); + + expect(elementFromPointMock).toHaveBeenCalledTimes(2); + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDragEnd).not.toHaveBeenCalled(); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should fallback to onDragEnd when elementFromPoint throws in fallback mouseup', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const elementFromPointMock = vi + .fn() + // fallback mousemove: return a non-dropzone element so drag can proceed + .mockImplementationOnce(() => firstName) + // fallback mouseup: throw and verify fallback path still calls onDragEnd + .mockImplementation(() => { + throw new Error('boom'); + }); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + + const onDrop = vi.fn(); + const onDragEnd = vi.fn(); + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDrop, + onDragEnd, + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 20, clientY: 20 })); + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 20, clientY: 20 })); + + expect(onDrop).not.toHaveBeenCalled(); + expect(onDragEnd).toHaveBeenCalledWith(['firstName']); + expect(elementFromPointMock).toHaveBeenCalledTimes(2); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should remove Firefox fallback mousedown listener on destroy', () => { + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + const removeLeftSpy = vi.spyOn(headerLeft, 'removeEventListener'); + const removeRightSpy = vi.spyOn(headerRight, 'removeEventListener'); + + const instance = setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + instance.destroy(); + + expect(removeLeftSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true); + expect(removeRightSpy).toHaveBeenCalledWith('mousedown', expect.any(Function), true); + + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should return early in dropzone fallback mousemove when target resolves to dragged pill', () => { + const originalUserAgent = window.navigator.userAgent; + Object.defineProperty(window.navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 Firefox/128.0 Linux x86_64', + }); + + const dropzoneElm = document.createElement('div'); + const firstPill = document.createElement('div'); + const secondPill = document.createElement('div'); + firstPill.className = 'slick-dropped-grouping'; + secondPill.className = 'slick-dropped-grouping'; + firstPill.dataset.id = 'age'; + secondPill.dataset.id = 'medals'; + dropzoneElm.append(firstPill, secondPill); + document.body.append(dropzoneElm); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => firstPill as Element), // ensures target === draggedPill + }); + + const onPillDragEnd = vi.fn(); + const instance = setupDropzonePillDrag({ + dropzoneElm, + onPillDragEnd, + }); + + firstPill.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstPill })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 20, clientY: 20 })); + + // Should stay unchanged because fallback mousemove returned early when target === draggedPill. + expect(Array.from(dropzoneElm.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['age', 'medals']); + + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 20, clientY: 20 })); + expect(onPillDragEnd).toHaveBeenCalledTimes(1); + + instance.destroy(); + Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: originalUserAgent }); + }); + + it('should keep dropzone fallback drag uncommitted when mousemove stays below threshold', () => { + const originalUserAgent = window.navigator.userAgent; + Object.defineProperty(window.navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 Firefox/128.0 Linux x86_64', + }); + + const dropzoneElm = document.createElement('div'); + const firstPill = document.createElement('div'); + const secondPill = document.createElement('div'); + firstPill.className = 'slick-dropped-grouping'; + secondPill.className = 'slick-dropped-grouping'; + firstPill.dataset.id = 'age'; + secondPill.dataset.id = 'medals'; + dropzoneElm.append(firstPill, secondPill); + document.body.append(dropzoneElm); + + const elementFromPointMock = vi.fn(() => secondPill as Element); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: elementFromPointMock, + }); + + const onPillDragEnd = vi.fn(); + const instance = setupDropzonePillDrag({ + dropzoneElm, + onPillDragEnd, + }); + + firstPill.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstPill })); + const moveEvt = createMouseEvent('mousemove', { clientX: 12, clientY: 12 }); + const preventDefaultSpy = vi.spyOn(moveEvt, 'preventDefault'); + document.dispatchEvent(moveEvt); + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 12, clientY: 12 })); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(elementFromPointMock).not.toHaveBeenCalled(); + expect(Array.from(dropzoneElm.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['age', 'medals']); + expect(onPillDragEnd).toHaveBeenCalledTimes(1); + + instance.destroy(); + Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: originalUserAgent }); + }); + + it('should forward native dropzone dragenter, dragleave, and drop callbacks when no pill is dragged', () => { + const dropzoneElm = document.createElement('div'); + document.body.append(dropzoneElm); + + const onColumnDragEnter = vi.fn(); + const onColumnDragLeave = vi.fn(); + const onColumnDrop = vi.fn(); + setupDropzonePillDrag({ + dropzoneElm, + onColumnDragEnter, + onColumnDragLeave, + onColumnDrop, + }); + + const dragEnterEvt = createDragEvent('dragenter'); + const dragEnterPreventDefaultSpy = vi.spyOn(dragEnterEvt, 'preventDefault'); + dropzoneElm.dispatchEvent(dragEnterEvt); + expect(onColumnDragEnter).toHaveBeenCalledWith(dragEnterEvt); + expect(dragEnterPreventDefaultSpy).toHaveBeenCalled(); + + const dragLeaveEvt = createDragEvent('dragleave'); + dropzoneElm.dispatchEvent(dragLeaveEvt); + expect(onColumnDragLeave).toHaveBeenCalledWith(dragLeaveEvt); + + const dropEvt = createDragEvent('drop', { + dataTransfer: { getData: vi.fn(() => 'firstName') }, + }); + const dropPreventDefaultSpy = vi.spyOn(dropEvt, 'preventDefault'); + dropzoneElm.dispatchEvent(dropEvt); + expect(dropPreventDefaultSpy).toHaveBeenCalled(); + expect(onColumnDrop).toHaveBeenCalledWith('firstName', dropEvt); + }); + + it('should reorder dropzone pills with native drag and remove dragging class on dragend', () => { + const dropzoneElm = document.createElement('div'); + const firstPill = document.createElement('div'); + const secondPill = document.createElement('div'); + firstPill.className = 'slick-dropped-grouping'; + secondPill.className = 'slick-dropped-grouping'; + firstPill.dataset.id = 'age'; + secondPill.dataset.id = 'medals'; + dropzoneElm.append(firstPill, secondPill); + document.body.append(dropzoneElm); + + vi.spyOn(secondPill, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + + const onPillDragEnd = vi.fn(); + setupDropzonePillDrag({ + dropzoneElm, + draggingCssClass: 'dragging', + onPillDragEnd, + }); + + const dragStartEvt = createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + target: firstPill, + }); + firstPill.dispatchEvent(dragStartEvt); + + const dragOverEvt = createDragEvent('dragover', { clientX: 139, clientY: 10, target: secondPill }); + secondPill.dispatchEvent(dragOverEvt); + + expect(Array.from(dropzoneElm.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['medals', 'age']); + + const dragEndEvt = createDragEvent('dragend'); + firstPill.dispatchEvent(dragEndEvt); + + expect(firstPill.classList.contains('dragging')).toBe(false); + expect(onPillDragEnd).toHaveBeenCalledWith(firstPill); + }); + + it('should call onPillDragEnd on native dragend even without draggingCssClass', () => { + const dropzoneElm = document.createElement('div'); + const firstPill = document.createElement('div'); + firstPill.className = 'slick-dropped-grouping'; + firstPill.dataset.id = 'age'; + dropzoneElm.append(firstPill); + document.body.append(dropzoneElm); + + const onPillDragEnd = vi.fn(); + setupDropzonePillDrag({ + dropzoneElm, + onPillDragEnd, + }); + + firstPill.dispatchEvent( + createDragEvent('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() }, + clientX: 10, + clientY: 10, + target: firstPill, + }) + ); + firstPill.dispatchEvent(createDragEvent('dragend')); + + expect(onPillDragEnd).toHaveBeenCalledWith(firstPill); + }); + + it('should reorder dropzone pills and clear dragging class on touch fallback', () => { + const dropzoneElm = document.createElement('div'); + const firstPill = document.createElement('div'); + const secondPill = document.createElement('div'); + firstPill.className = 'slick-dropped-grouping'; + secondPill.className = 'slick-dropped-grouping'; + firstPill.dataset.id = 'age'; + secondPill.dataset.id = 'medals'; + dropzoneElm.append(firstPill, secondPill); + document.body.append(dropzoneElm); + + vi.spyOn(secondPill, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => secondPill as Element), + }); + + const onPillDragEnd = vi.fn(); + setupDropzonePillDrag({ + dropzoneElm, + draggingCssClass: 'dragging', + onPillDragEnd, + }); + + firstPill.dispatchEvent( + createTouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 10, pageX: 10 }], + changedTouches: [{ clientX: 10, clientY: 10, pageX: 10 }], + target: firstPill, + }) + ); + expect(firstPill.classList.contains('dragging')).toBe(true); + + document.dispatchEvent( + createTouchEvent('touchmove', { + touches: [{ clientX: 139, clientY: 10, pageX: 139 }], + changedTouches: [{ clientX: 139, clientY: 10, pageX: 139 }], + }) + ); + document.dispatchEvent( + createTouchEvent('touchend', { + touches: [], + changedTouches: [{ clientX: 139, clientY: 10, pageX: 139 }], + }) + ); + + expect(Array.from(dropzoneElm.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['medals', 'age']); + expect(firstPill.classList.contains('dragging')).toBe(false); + expect(onPillDragEnd).toHaveBeenCalledWith(firstPill); + }); + + it('should start left auto-scroll when pointer moves past left viewport edge during fallback drag (line 331)', () => { + // In jsdom: getOffset() returns {left:0}, container.clientWidth=0 → viewportLeft=0, containerRight=0. + // pageX = -5 satisfies !columnScrollTimer && pageX < viewportLeft(0) → hits scrollColumnsLeft branch. + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: vi.fn(() => null) }); + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval').mockReturnValue(99 as any); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 0, clientY: 10, pageX: -5 })); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 100); + + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 0, clientY: 10 })); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); + + it('should stop auto-scroll when pointer returns within viewport bounds during fallback drag (line 333)', () => { + // First mousemove with pageX=5 > containerRight(0) starts the scroll timer. + // Second mousemove with pageX=0 satisfies columnScrollTimer && pageX<=0 && pageX>=0 → stopAutoScroll. + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'Mozilla/5.0 Firefox/128.0 Linux x86_64' }, + }); + + const headerLeft = document.createElement('div'); + const headerRight = document.createElement('div'); + const viewport = document.createElement('div'); + const container = document.createElement('div'); + const firstName = createHeaderColumn('firstName'); + headerLeft.append(firstName); + + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: vi.fn(() => null) }); + vi.spyOn(globalThis, 'setInterval').mockReturnValue(99 as any); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); + + setupColumnReorderDrag({ + headerLeft, + headerRight, + container, + viewportScrollContainerX: viewport, + hasFrozenColumns: () => false, + onDragEnd: vi.fn(), + }); + + firstName.dispatchEvent(createMouseEvent('mousedown', { clientX: 10, clientY: 10, target: firstName })); + // starts right-scroll timer (pageX > containerRight=0) + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 5, clientY: 10, pageX: 5 })); + // stops timer (pageX=0, within [viewportLeft=0, containerRight=0]) + document.dispatchEvent(createMouseEvent('mousemove', { clientX: 0, clientY: 10, pageX: 0 })); + + expect(clearIntervalSpy).toHaveBeenCalledWith(99); + + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 0, clientY: 10 })); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }); + }); +}); diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index afcd863abf..7c0c974f76 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -3437,31 +3437,38 @@ describe('SlickGrid core file', () => { { id: 0, firstName: 'John', lastName: 'Doe', age: 30 }, { id: 1, firstName: 'Jane', lastName: 'Doe', age: 28 }, ]; - let sortInstance: any; + + /** Helper: dispatch a dragstart event on a column element (bubbles to parent handler) */ + const fireDragStart = (el: HTMLElement) => { + const evt = new Event('dragstart', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(evt, 'dataTransfer', { value: { effectAllowed: '', setDragImage: vi.fn() } }); + Object.defineProperty(evt, 'offsetX', { value: 10 }); + Object.defineProperty(evt, 'offsetY', { value: 5 }); + el.dispatchEvent(evt); + return evt; + }; + + /** Helper: dispatch a dragend event on a column element */ + const fireDragEnd = (el: HTMLElement) => { + const evt = new Event('dragend', { bubbles: true, cancelable: true }) as DragEvent; + el.dispatchEvent(evt); + return evt; + }; + + /** Helper: find a header column by its data-id attribute */ + const findCol = (parent: HTMLElement, id: string) => parent.querySelector(`[data-id="${id}"]`)!; it('should reorder column to the left when current column pageX is lower than viewport left position', () => { grid = new SlickGrid(container, data, columns, defaultOptions); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; const onColumnsReorderedSpy = vi.spyOn(grid.onColumnsReordered, 'notify'); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as any; - - const dragEvent = new CustomEvent('DragEvent'); vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'related', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(headerColumnElms[0], 'clientLeft', { writable: true, value: 25 }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - vi.spyOn(sortInstance, 'toArray').mockReturnValueOnce(['firstName', 'age', 'lastName']); // simulate column order change - expect(sortInstance).toBeTruthy(); - sortInstance.options.onStart(dragEvent); - sortInstance.options.onMove(dragEvent); + fireDragStart(findCol(headerL, 'firstName')); + + // simulate reorder: age moved before lastName → [firstName, age, lastName] + headerL.insertBefore(findCol(headerL, 'age'), findCol(headerL, 'lastName')); const docDragEvt = new Event('drag') as DragEvent; Object.defineProperty(docDragEvt, 'pageX', { writable: true, value: 20 }); // pageX < viewportLeft → scroll left @@ -3471,33 +3478,23 @@ describe('SlickGrid core file', () => { expect(viewportTopLeft.scrollLeft).toBe(0); vi.advanceTimersByTime(100); - expect(viewportTopLeft.scrollLeft).toBe(-10); - sortInstance.options.onEnd(dragEvent); + + fireDragEnd(findCol(headerL, 'firstName')); expect(onColumnsReorderedSpy).toHaveBeenCalled(); }); it('should reorder column to the right when current column pageX is greater than container width', () => { grid = new SlickGrid(container, data, columns, defaultOptions); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; const onColumnsReorderedSpy = vi.spyOn(grid.onColumnsReordered, 'notify'); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); - const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; - - const dragEvent = new CustomEvent('DragEvent'); + const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as any; vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(headerColumnElms[0], 'clientLeft', { writable: true, value: 25 }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - vi.spyOn(sortInstance, 'toArray').mockReturnValueOnce(['firstName', 'age', 'lastName']); // simulate column order change - expect(sortInstance).toBeTruthy(); - sortInstance.options.onStart(dragEvent); + fireDragStart(findCol(headerL, 'firstName')); + + // simulate reorder: age moved before lastName → [firstName, age, lastName] + headerL.insertBefore(findCol(headerL, 'age'), findCol(headerL, 'lastName')); const docDragEvt = new Event('drag') as DragEvent; Object.defineProperty(docDragEvt, 'pageX', { writable: true, value: DEFAULT_GRID_WIDTH + 11 }); // pageX > containerRight → scroll right @@ -3507,63 +3504,44 @@ describe('SlickGrid core file', () => { expect(viewportTopLeft.scrollLeft).toBe(0); vi.advanceTimersByTime(100); - expect(viewportTopLeft.scrollLeft).toBe(10); - sortInstance.options.onEnd(dragEvent); + + fireDragEnd(findCol(headerL, 'firstName')); expect(onColumnsReorderedSpy).toHaveBeenCalled(); }); it('should not trigger "onColumnsReordered" neither reorder column when column order is the same', () => { grid = new SlickGrid(container, data, columns, defaultOptions); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; const onColumnsReorderedSpy = vi.spyOn(grid.onColumnsReordered, 'notify'); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); - const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; - const dragEvent = new CustomEvent('DragEvent'); - vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(headerColumnElms[0], 'clientLeft', { writable: true, value: 25 }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - vi.spyOn(sortInstance, 'toArray').mockReturnValueOnce(['firstName', 'lastName', 'age']); // simulate same column order as before + fireDragStart(findCol(headerL, 'firstName')); + // no DOM manipulation → same order as original - expect(sortInstance).toBeTruthy(); - sortInstance.options.onStart(dragEvent); + fireDragEnd(findCol(headerL, 'firstName')); + expect(onColumnsReorderedSpy).not.toHaveBeenCalled(); + }); - const docDragEvt = new Event('drag') as DragEvent; - Object.defineProperty(docDragEvt, 'pageX', { writable: true, value: DEFAULT_GRID_WIDTH + 11 }); - Object.defineProperty(docDragEvt, 'clientX', { writable: true, value: DEFAULT_GRID_WIDTH + 11 }); - Object.defineProperty(docDragEvt, 'clientY', { writable: true, value: 10 }); - document.dispatchEvent(docDragEvt); - expect(viewportTopLeft.scrollLeft).toBe(0); + it('should ignore dragend when dragstart did not initialize reorder state', () => { + grid = new SlickGrid(container, data, columns, defaultOptions); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; + const onColumnsReorderedSpy = vi.spyOn(grid.onColumnsReordered, 'notify'); + const initialOrder = grid.getColumns().map((col) => col.id); - vi.advanceTimersByTime(100); + // Trigger dragend directly so onDragEnd runs with undefined columnMap. + fireDragEnd(findCol(headerL, 'firstName')); - expect(viewportTopLeft.scrollLeft).toBe(10); - sortInstance.options.onEnd(dragEvent); - expect(onColumnsReorderedSpy).not.toHaveBeenCalled(); // same order won't call event + expect(onColumnsReorderedSpy).not.toHaveBeenCalled(); + expect(grid.getColumns().map((col) => col.id)).toEqual(initialOrder); }); it('should stop auto-scroll when cursor moves back into safe zone during drag', () => { grid = new SlickGrid(container, data, columns, defaultOptions); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; - const dragEvent = new CustomEvent('DragEvent'); vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - sortInstance.options.onStart(dragEvent); + fireDragStart(findCol(headerL, 'firstName')); // first drag beyond container right → starts scroll timer const docDragEvt = new Event('drag') as DragEvent; @@ -3572,86 +3550,56 @@ describe('SlickGrid core file', () => { Object.defineProperty(docDragEvt, 'clientY', { writable: true, value: 10 }); document.dispatchEvent(docDragEvt); vi.advanceTimersByTime(100); - expect(viewportTopLeft.scrollLeft).toBe(10); // scrolled right + expect(viewportTopLeft.scrollLeft).toBe(10); - // now move cursor back into safe zone → timer should stop + // move cursor back into safe zone → timer stops const docDragSafe = new Event('drag') as DragEvent; Object.defineProperty(docDragSafe, 'pageX', { writable: true, value: DEFAULT_GRID_WIDTH / 2 }); Object.defineProperty(docDragSafe, 'clientX', { writable: true, value: DEFAULT_GRID_WIDTH / 2 }); Object.defineProperty(docDragSafe, 'clientY', { writable: true, value: 10 }); document.dispatchEvent(docDragSafe); - viewportTopLeft.scrollLeft = 0; // reset to confirm timer is stopped + viewportTopLeft.scrollLeft = 0; vi.advanceTimersByTime(100); expect(viewportTopLeft.scrollLeft).toBe(0); // no more auto-scrolling - sortInstance.options.onEnd(dragEvent); + fireDragEnd(findCol(headerL, 'firstName')); }); it('should try reordering column but stay at same scroll position when grid has frozen columns', () => { grid = new SlickGrid(container, data, columns, { ...defaultOptions, frozenColumn: 0 }); grid.setActiveCell(0, 1); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); + // frozenColumn: 0 → headerL has [firstName], headerR has [lastName, age] + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; const onColumnsReorderedSpy = vi.spyOn(grid.onColumnsReordered, 'notify'); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; - - const dragEvent = new CustomEvent('DragEvent'); vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); // frozen left column - Object.defineProperty(headerColumnElms[0], 'clientLeft', { writable: true, value: 25 }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - expect(sortInstance).toBeTruthy(); - sortInstance.options.onStart(dragEvent); + // drag the frozen left column (canAutoScroll = false → no scroll) + const frozenColEl = headerL.querySelector('.slick-header-column')!; + fireDragStart(frozenColEl); - // even if drag event fires, canDragScroll=false for frozen left column → no scroll const docDragEvt = new Event('drag') as DragEvent; Object.defineProperty(docDragEvt, 'pageX', { writable: true, value: DEFAULT_GRID_WIDTH + 11 }); Object.defineProperty(docDragEvt, 'clientX', { writable: true, value: DEFAULT_GRID_WIDTH + 11 }); Object.defineProperty(docDragEvt, 'clientY', { writable: true, value: 10 }); document.dispatchEvent(docDragEvt); - expect(viewportTopLeft.scrollLeft).toBe(0); - vi.advanceTimersByTime(100); + expect(viewportTopLeft.scrollLeft).toBe(0); // frozen left column cannot trigger scroll - expect(viewportTopLeft.scrollLeft).toBe(0); // same position, frozen column can't trigger scroll - sortInstance.options.onEnd(dragEvent); - expect(onColumnsReorderedSpy).not.toHaveBeenCalled(); + fireDragEnd(frozenColEl); + expect(onColumnsReorderedSpy).not.toHaveBeenCalled(); // same order }); it('should not allow column reordering when Editor Lock commitCurrentEdit() is failing', () => { grid = new SlickGrid(container, data, columns, { ...defaultOptions, frozenColumn: 0 }); vi.spyOn(grid.getEditorLock(), 'commitCurrentEdit').mockReturnValueOnce(false); - const headerColumnsElm: any = document.querySelector('.slick-header-columns.slick-header-columns-left'); - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; const onColumnsReorderedSpy = vi.spyOn(grid.onColumnsReordered, 'notify'); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); - const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; - - const dragEvent = new CustomEvent('DragEvent'); - vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'originalEvent', { writable: true, value: { pageX: 20 } }); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - - expect(sortInstance).toBeTruthy(); - sortInstance.options.onStart(dragEvent); - expect(viewportTopLeft.scrollLeft).toBe(0); - - vi.advanceTimersByTime(100); - expect(viewportTopLeft.scrollLeft).toBe(0); - sortInstance.options.onEnd(dragEvent); + const frozenColEl = headerL.querySelector('.slick-header-column')!; + fireDragStart(frozenColEl); + fireDragEnd(frozenColEl); expect(onColumnsReorderedSpy).not.toHaveBeenCalled(); }); @@ -3663,31 +3611,16 @@ describe('SlickGrid core file', () => { { id: 'age', field: 'age', name: 'Age', sortable: true }, ] as Column[]; grid = new SlickGrid(container, data, columnsWithHidden, defaultOptions); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; const onColumnsReorderedSpy = vi.spyOn(grid.onColumnsReordered, 'notify'); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); - const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; - - const dragEvent = new CustomEvent('DragEvent'); - vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'originalEvent', { writable: true, value: { pageX: 20 } }); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(headerColumnElms[0], 'clientLeft', { writable: true, value: 25 }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - // SortableJS only returns visible columns (firstName, lastName, age) - // Simulate moving lastName before firstName: ['lastName', 'firstName', 'age'] - vi.spyOn(sortInstance, 'toArray').mockReturnValueOnce(['lastName', 'firstName', 'age']); - sortInstance.options.onStart(dragEvent); - sortInstance.options.onEnd(dragEvent); + fireDragStart(findCol(headerL, 'firstName')); + // Simulate: lastName moved before firstName → [lastName, firstName, age] + headerL.insertBefore(findCol(headerL, 'lastName'), findCol(headerL, 'firstName')); + fireDragEnd(findCol(headerL, 'lastName')); const finalColumns = grid.getColumns(); - // Expected order: lastName, firstName, middleName (hidden stays at index 2), age + // Expected: lastName, firstName, middleName (hidden stays at index 2), age expect(grid.getColumnByIdx(0)!.id).toBe('lastName'); expect(finalColumns[0].id).toBe('lastName'); expect(finalColumns[1].id).toBe('firstName'); @@ -3706,26 +3639,12 @@ describe('SlickGrid core file', () => { { id: 'age', field: 'age', name: 'Age', sortable: true }, ] as Column[]; grid = new SlickGrid(container, data, columnsWithMultipleHidden, defaultOptions); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); - const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; - - const dragEvent = new CustomEvent('DragEvent'); - vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'originalEvent', { writable: true, value: { pageX: 20 } }); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(headerColumnElms[0], 'clientLeft', { writable: true, value: 25 }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - // Simulate reordering visible columns: ['age', 'firstName', 'lastName'] - vi.spyOn(sortInstance, 'toArray').mockReturnValueOnce(['age', 'firstName', 'lastName']); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; - sortInstance.options.onStart(dragEvent); - sortInstance.options.onEnd(dragEvent); + fireDragStart(findCol(headerL, 'firstName')); + // Simulate: ['age', 'firstName', 'lastName'] — move age before firstName + headerL.insertBefore(findCol(headerL, 'age'), findCol(headerL, 'firstName')); + fireDragEnd(findCol(headerL, 'age')); const finalColumns = grid.getColumns(); // Expected: age, middleName(hidden at idx 1), firstName, suffix(hidden at idx 3), lastName @@ -3746,26 +3665,13 @@ describe('SlickGrid core file', () => { { id: 'age', field: 'age', name: 'Age', sortable: true }, ] as Column[]; grid = new SlickGrid(container, data, columnsWithHiddenFirst, defaultOptions); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); - const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; - const dragEvent = new CustomEvent('DragEvent'); - vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'originalEvent', { writable: true, value: { pageX: 20 } }); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(headerColumnElms[0], 'clientLeft', { writable: true, value: 25 }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - // Reorder visible columns: ['lastName', 'age', 'firstName'] - vi.spyOn(sortInstance, 'toArray').mockReturnValueOnce(['lastName', 'age', 'firstName']); - - sortInstance.options.onStart(dragEvent); - sortInstance.options.onEnd(dragEvent); + fireDragStart(findCol(headerL, 'firstName')); + // Simulate: ['lastName', 'age', 'firstName'] + headerL.insertBefore(findCol(headerL, 'lastName'), findCol(headerL, 'firstName')); + headerL.insertBefore(findCol(headerL, 'age'), findCol(headerL, 'firstName')); + fireDragEnd(findCol(headerL, 'lastName')); const finalColumns = grid.getColumns(); // Expected: id(hidden at idx 0), lastName, age, firstName @@ -3784,26 +3690,12 @@ describe('SlickGrid core file', () => { { id: 'metadata', field: 'metadata', name: 'Metadata', sortable: true, hidden: true }, ] as Column[]; grid = new SlickGrid(container, data, columnsWithHiddenLast, defaultOptions); - const headerColumnsElm = document.querySelector('.slick-header-columns.slick-header-columns-left') as any; - Object.keys(headerColumnsElm).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = headerColumnsElm[prop]; - } - }); - const headerColumnElms = document.querySelectorAll('.slick-header-column'); - const viewportTopLeft = document.querySelector('.slick-viewport-top.slick-viewport-left') as HTMLDivElement; + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; - const dragEvent = new CustomEvent('DragEvent'); - vi.spyOn(viewportTopLeft, 'getBoundingClientRect').mockReturnValue({ left: 25, top: 10, right: 0, bottom: 0 } as DOMRect); - Object.defineProperty(dragEvent, 'originalEvent', { writable: true, value: { pageX: 20 } }); - Object.defineProperty(dragEvent, 'item', { writable: true, value: headerColumnElms[0] }); - Object.defineProperty(headerColumnElms[0], 'clientLeft', { writable: true, value: 25 }); - Object.defineProperty(viewportTopLeft, 'clientLeft', { writable: true, value: 25 }); - // Reorder visible columns: ['age', 'firstName', 'lastName'] - vi.spyOn(sortInstance, 'toArray').mockReturnValueOnce(['age', 'firstName', 'lastName']); - - sortInstance.options.onStart(dragEvent); - sortInstance.options.onEnd(dragEvent); + fireDragStart(findCol(headerL, 'firstName')); + // Simulate: ['age', 'firstName', 'lastName'] + headerL.insertBefore(findCol(headerL, 'age'), findCol(headerL, 'firstName')); + fireDragEnd(findCol(headerL, 'age')); const finalColumns = grid.getColumns(); // Expected: age, firstName, lastName, metadata(hidden at idx 3) @@ -3813,6 +3705,27 @@ describe('SlickGrid core file', () => { expect(finalColumns[3].id).toBe('metadata'); expect(finalColumns[3].hidden).toBe(true); }); + + it('should preserve non-reorderable visible columns at their original index while reordering others', () => { + const columnsWithFixedMiddle = [ + { id: 'firstName', field: 'firstName', name: 'First Name', sortable: true }, + { id: 'lastName', field: 'lastName', name: 'Last Name', sortable: true, reorderable: false }, + { id: 'age', field: 'age', name: 'Age', sortable: true }, + ] as Column[]; + grid = new SlickGrid(container, data, columnsWithFixedMiddle, defaultOptions); + const headerL = document.querySelector('.slick-header-columns.slick-header-columns-left')!; + + fireDragStart(findCol(headerL, 'firstName')); + // Simulate visible draggable order: [age, firstName] while fixed lastName stays excluded from draggable list + headerL.insertBefore(findCol(headerL, 'age'), findCol(headerL, 'firstName')); + fireDragEnd(findCol(headerL, 'age')); + + const finalColumns = grid.getColumns(); + expect(finalColumns[0].id).toBe('age'); + expect(finalColumns[1].id).toBe('lastName'); + expect(finalColumns[1].reorderable).toBe(false); + expect(finalColumns[2].id).toBe('firstName'); + }); }); describe('Drag & Drop (Draggable)', () => { diff --git a/packages/common/src/core/index.ts b/packages/common/src/core/index.ts index 7cad82698c..2c4efa0bf9 100644 --- a/packages/common/src/core/index.ts +++ b/packages/common/src/core/index.ts @@ -1,3 +1,4 @@ +export * from './slickColumnReorderDrag.js'; export * from './slickCore.js'; export * from './slickDataview.js'; export * from './slickGrid.js'; diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts new file mode 100644 index 0000000000..540da6ab52 --- /dev/null +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -0,0 +1,743 @@ +import { getOffset } from '@slickgrid-universal/utils'; +import type { ColumnReorderDragOption, DropzonePillDragOption } from '../interfaces/slickColumnReorderDrag.interfaces.js'; + +/** Extract { clientX, clientY, pageX } from any pointer-like event. + * DragEvent inherits clientX/pageX from MouseEvent; TouchEvent uses touches[0] (or + * changedTouches[0] for touchend/touchcancel where touches is empty). */ +const getPointerPos = (e: DragEvent | MouseEvent | TouchEvent) => { + if ('touches' in e) { + const t = e.touches[0] ?? e.changedTouches[0]; + return { clientX: t?.clientX ?? 0, clientY: t?.clientY ?? 0, pageX: t?.pageX ?? 0 }; + } + return { clientX: e.clientX, clientY: e.clientY, pageX: e.pageX }; +}; + +/** + * Sets up native HTML5 drag-and-drop for column header reordering. + * + * Handles: + * - Making orderable header columns draggable + * - Live DOM reordering during dragover + * - Browser-edge auto-scroll (left / right) during drag + * - Firefox+Linux ghost rendering fix via explicit setDragImage + * - Storing the dragged column's data-id in dataTransfer (for cross-container drops) + * + * @returns `{ destroy }` – call to remove all listeners and clear draggable attributes. + */ +export function setupColumnReorderDrag(options: ColumnReorderDragOption): { destroy: () => void } { + const { headerLeft, headerRight, container, viewportScrollContainerX, unorderableColumnCssClass } = options; + const dragActiveClass = options.dragActiveClass ?? 'slick-header-column-active'; + const draggableSelector = options.draggableSelector ?? '.slick-header-column'; + const dropzoneSelector = options.dropzoneSelector ?? '.slick-dropzone'; + const dropzoneHoverClass = options.dropzoneHoverClass ?? 'slick-dropzone-hover'; + const DRAG_THRESHOLD = 5; // pixels before we consider it a drag, not a click + const INTERVAL_TIME = 100; // ms for browser-edge auto-scroll + + let columnScrollTimer: ReturnType | undefined; + let draggedEl: HTMLElement | null = null; + let originalParent: Node | null = null; + let originalNextSibling: ChildNode | null = null; + let dropzoneTargetActive = false; + let draggedColumnId = ''; + let _lastClientX: number | null = null; + let dragGhost: HTMLElement | null = null; + let dragStartX: number | null = null; + let dragStartY: number | null = null; + let pointerDragCommitted = false; + + const isOverDropzone = (el: HTMLElement | null | undefined): boolean => !!el?.closest?.(dropzoneSelector); + const scrollColumnsRight = () => (viewportScrollContainerX.scrollLeft += 10); + const scrollColumnsLeft = () => (viewportScrollContainerX.scrollLeft -= 10); + const stopAutoScroll = () => { + clearInterval(columnScrollTimer); + columnScrollTimer = undefined; + }; + + const restoreDraggedToOriginalParent = () => { + if (originalParent && draggedEl && draggedEl.parentElement !== originalParent) { + originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); + } + }; + + const toggleDropzoneHoverClass = (el: HTMLElement | null | undefined, isActive: boolean) => { + const dropzone = el?.closest?.(dropzoneSelector) as HTMLElement | null; + dropzone?.classList.toggle(dropzoneHoverClass, isActive); + }; + + const clearDropzoneHoverClasses = () => { + document.querySelectorAll(dropzoneSelector).forEach((el) => el.classList.remove(dropzoneHoverClass)); + }; + + const safely = (operation: () => void, onError?: (error: unknown) => void) => { + try { + operation(); + } catch (error) { + onError?.(error); + } + }; + + const isDragStartIgnoredTarget = (el: HTMLElement | null, event: DragEvent | MouseEvent | TouchEvent): boolean => { + const dragStartFilter = options.dragStartFilter; + return typeof dragStartFilter === 'function' + ? !!dragStartFilter(el, event) + : typeof dragStartFilter === 'string' && dragStartFilter.trim() + ? !!el?.closest?.(dragStartFilter) + : false; + }; + + const reorderDraggedAgainstTarget = (target: HTMLElement, clientX: number) => { + if (!draggedEl || target === draggedEl || !isDraggable(target)) { + return; + } + + // Only allow reordering within the dragged column's own header container so columns + // can never cross the frozen-column boundary (same behavior as SortableJS's two + // unconnected lists). + const targetParent = target.parentElement; + if (!targetParent || targetParent !== originalParent) { + return; + } + + const rect = target.getBoundingClientRect(); + const movingRight = _lastClientX == null ? clientX >= rect.left + rect.width / 2 : clientX > _lastClientX; + _lastClientX = clientX; + const insertBefore = !movingRight; + targetParent.insertBefore(draggedEl, insertBefore ? target : target.nextSibling); + }; + + const isDraggable = (el: HTMLElement): boolean => + el.matches(draggableSelector) && (!unorderableColumnCssClass || !el.classList.contains(unorderableColumnCssClass)); + + // Mirror SortableJS's Firefox/Linux fallback detection so the mouse-based path is used only for the broken browser combo. + const isFfLinux = typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) && /linux/i.test(navigator.userAgent); + + const getColumnIds = (parent: HTMLElement): string[] => + Array.from(parent.children) + .filter((el) => isDraggable(el as HTMLElement)) + .map((el) => (el as HTMLElement).dataset.id ?? '') + .filter(Boolean); + + // Set draggable attribute on all eligible header columns + const refreshDraggable = (parent: HTMLElement) => { + Array.from(parent.children as HTMLCollectionOf).forEach((el) => { + if (el.matches(draggableSelector)) { + // Disable native HTML5 drag on Firefox/Linux and use mouse fallback + el.draggable = isDraggable(el) && !isFfLinux; + } + }); + }; + refreshDraggable(headerLeft); + refreshDraggable(headerRight); + + const autoScrollHandler = (e: DragEvent) => { + const { clientX, clientY, pageX } = e; + if (clientX != null && clientY != null) { + const containerOffset = getOffset(container); + const viewportLeft = getOffset(viewportScrollContainerX).left; + const containerRight = containerOffset.left + container.clientWidth; + if (!columnScrollTimer && pageX > containerRight) { + columnScrollTimer = setInterval(scrollColumnsRight, INTERVAL_TIME); + } else if (!columnScrollTimer && pageX < viewportLeft) { + columnScrollTimer = setInterval(scrollColumnsLeft, INTERVAL_TIME); + } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { + stopAutoScroll(); + } + } + }; + + const clearDropzoneTarget = () => (dropzoneTargetActive = false); + const clearFallbackGhost = () => { + dragGhost?.parentElement?.removeChild(dragGhost); + dragGhost = null; + }; + + const resetDragState = () => { + draggedEl = null; + originalParent = null; + originalNextSibling = null; + draggedColumnId = ''; + _lastClientX = null; + dragStartX = null; + dragStartY = null; + pointerDragCommitted = false; + clearDropzoneTarget(); + clearDropzoneHoverClasses(); + clearFallbackGhost(); + }; + + const createFallbackGhost = (source: HTMLElement, clientX: number, clientY: number) => { + clearFallbackGhost(); + const rect = source.getBoundingClientRect(); + dragGhost = source.cloneNode(true) as HTMLElement; + dragGhost.classList.add('slick-header-column-drag-ghost'); + // Explicitly set dimensions and styles so the ghost renders correctly outside its original container + dragGhost.style.width = `${rect.width}px`; + dragGhost.style.height = `${rect.height}px`; + dragGhost.style.opacity = '0.8'; + dragGhost.style.left = `${clientX}px`; + dragGhost.style.top = `${clientY}px`; + document.body.appendChild(dragGhost); + }; + + const updateFallbackGhost = (clientX: number, clientY: number) => { + if (dragGhost) { + dragGhost.style.left = `${clientX}px`; + dragGhost.style.top = `${clientY}px`; + } + }; + + const onDropzoneDragEnter = (e: Event) => { + const target = e.target as HTMLElement | null; + if (draggedEl && isOverDropzone(target)) { + dropzoneTargetActive = true; + toggleDropzoneHoverClass(target, true); + // Ensure the dragged header remains in the header DOM (non-destructive) + safely(() => { + restoreDraggedToOriginalParent(); + }); + } + }; + + const onDropzoneDragLeave = (e: DragEvent) => { + const target = e.target as HTMLElement | null; + const relatedTarget = + (e.relatedTarget as HTMLElement | null) ?? + (e.clientX != null && e.clientY != null ? (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null) : null); + if (isOverDropzone(target) && !isOverDropzone(relatedTarget)) { + dropzoneTargetActive = false; + toggleDropzoneHoverClass(target, false); + } + }; + + document.addEventListener('dragenter', onDropzoneDragEnter as EventListener); + document.addEventListener('dragleave', onDropzoneDragLeave as EventListener); + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + const overDropzone = isOverDropzone(e.target as HTMLElement | null); + if (overDropzone) { + dropzoneTargetActive = true; + toggleDropzoneHoverClass(e.target as HTMLElement | null, true); + // Keep the dragged header visible in the original header DOM while over the dropzone + safely(() => { + restoreDraggedToOriginalParent(); + }); + return; + } + + clearDropzoneHoverClasses(); + + const target = (e.target as HTMLElement).closest(draggableSelector); + if (target) { + reorderDraggedAgainstTarget(target, e.clientX); + } + }; + + // Shared finalization logic for both `dragend` and `drop`-on-header paths. + const finalizeDrag = (e: DragEvent) => { + const draggedHeader = draggedEl; + draggedHeader?.classList.remove(dragActiveClass); + stopAutoScroll(); + document.removeEventListener('drag', autoScrollHandler as EventListener); + + // If the drop happened over an external dropzone (eg. DraggableGrouping), the header + // may have been moved out of the header DOM during dragover. Detect that and + // restore the header to its original parent to avoid permanently removing the column + // from the grid's column list when we read column order from the DOM. + let droppedOnDropzone = dropzoneTargetActive; + safely( + () => { + if (!droppedOnDropzone && e.clientX != null && e.clientY != null) { + const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; + if (el && el.closest && el.closest(dropzoneSelector)) { + droppedOnDropzone = true; + } + } + }, + () => { + droppedOnDropzone = false; + } + ); + + if (droppedOnDropzone && draggedHeader && originalParent) { + // restore the header DOM to its original location + originalParent.insertBefore(draggedHeader, originalNextSibling as Node | null); + } + + const reorderedIds = getColumnIds(headerLeft).concat(getColumnIds(headerRight)); + e.stopPropagation(); + if (droppedOnDropzone && draggedHeader) { + options.onDrop?.(draggedHeader, e, draggedColumnId); + } else { + options.onDragEnd(reorderedIds); + } + // clear stored original position (sets draggedEl = null, making a subsequent + // dragend that follows a drop a harmless no-op via the !draggedEl guard below) + resetDragState(); + }; + + const onDragEnd = (e: DragEvent) => { + if (!draggedEl) { + // Drag already finalized by the `drop` handler, or dragend fired without a prior + // dragstart (e.g. a stale event). Just make sure cleanup is done and return. + stopAutoScroll(); + document.removeEventListener('drag', autoScrollHandler as EventListener); + return; + } + finalizeDrag(e); + }; + + // Finalize on `drop` as well as `dragend` (like SortableJS did). Some drag sources + // dispatch `drop` without a following `dragend`; finalizing here ensures + // setColumns()/onColumnsReordered always run. When a real browser fires both events, + // the `dragend` handler above sees draggedEl === null (reset by finalizeDrag) and + // returns immediately, so there is no double-finalization. + const onHeaderDrop = (e: DragEvent) => { + e.preventDefault(); + if (draggedEl) { + finalizeDrag(e); + } + }; + + // Firefox/Linux native HTML5 drag is broken; touch screens never fire HTML5 drag events. + // All three start events (dragstart, mousedown, touchstart) share one handler. + + const onStart = (e: DragEvent | MouseEvent | TouchEvent) => { + const { clientX, clientY } = getPointerPos(e); + const eventTarget = e.target as HTMLElement | null; + const cancelNativeDragIfNeeded = (e: DragEvent | MouseEvent | TouchEvent) => { + if (e.type === 'dragstart') { + e.preventDefault(); + } + }; + + if (isDragStartIgnoredTarget(eventTarget, e)) { + cancelNativeDragIfNeeded(e); + return; + } + const target = eventTarget?.closest(draggableSelector); + if (!target || !isDraggable(target)) { + // Cancel a native drag that started on a non-orderable column + cancelNativeDragIfNeeded(e); + return; + } + // Common state setup + draggedEl = target; + draggedColumnId = target.dataset.id ?? ''; + clearDropzoneTarget(); + originalParent = target.parentElement; + originalNextSibling = target.nextSibling; + if (e.type === 'dragstart') { + options.onDragStart?.(target); + } + _lastClientX = clientX; + + if (e.type === 'dragstart') { + // Native HTML5 drag: configure dataTransfer and auto-scroll + // Add class immediately for native drag since it's committed + target.classList.add(dragActiveClass); + const de = e as DragEvent; + if (de.dataTransfer) { + de.dataTransfer.effectAllowed = 'move'; + // Store column id so the dropzone can identify which column was dragged + if (typeof de.dataTransfer.setData === 'function') { + de.dataTransfer.setData('text/plain', target.dataset.id ?? ''); + } + // Explicit drag image avoids Firefox+Linux ghost rendering issues. + // Use clientX/Y minus the target rect to get the offset relative to the + // actual column header element (e.offsetX/Y is relative to e.target which + // may be a child span, causing a wrong ghost position on Firefox/Linux). + if (typeof de.dataTransfer.setDragImage === 'function') { + const rect = target.getBoundingClientRect(); + de.dataTransfer.setDragImage(target, clientX - rect.left, clientY - rect.top); + } + } + // Only non-frozen columns should trigger browser-edge auto-scroll + const canAutoScroll = !options.hasFrozenColumns() || headerRight.contains(target); + if (canAutoScroll) { + document.addEventListener('drag', autoScrollHandler as EventListener); + } + } else { + // Pointer fallback (mouse on FF/Linux, touch on all platforms) + dragStartX = clientX; + dragStartY = clientY; + pointerDragCommitted = false; + if ('touches' in e) { + document.addEventListener('touchmove', onPointerMove as EventListener, { passive: false }); + document.addEventListener('touchend', onPointerUp as EventListener); + document.addEventListener('touchcancel', onPointerUp as EventListener); + } else { + document.addEventListener('mousemove', onPointerMove as EventListener); + document.addEventListener('mouseup', onPointerUp as EventListener); + } + } + }; + + const onPointerMove = (e: MouseEvent | TouchEvent) => { + if (draggedEl && dragStartX != null && dragStartY != null) { + const { clientX, clientY, pageX } = getPointerPos(e); + + // Check if we've exceeded the drag threshold + if (!pointerDragCommitted) { + const deltaX = Math.abs(clientX - dragStartX); + const deltaY = Math.abs(clientY - dragStartY); + if (deltaX < DRAG_THRESHOLD && deltaY < DRAG_THRESHOLD) { + // Haven't moved far enough yet - don't commit to drag + return; + } + // Threshold exceeded - now commit to drag + e.preventDefault(); + options.onDragStart?.(draggedEl); + createFallbackGhost(draggedEl, clientX, clientY); + draggedEl.classList.add(dragActiveClass); + // Disable text selection only after drag intent is confirmed + document.body.style.userSelect = 'none'; + pointerDragCommitted = true; + } + + // Always update ghost position for visual feedback once drag is committed + updateFallbackGhost(clientX, clientY); + + e.preventDefault(); + + // browser-edge auto-scroll + const containerOffset = getOffset(container); + const viewportLeft = getOffset(viewportScrollContainerX).left; + const containerRight = containerOffset.left + container.clientWidth; + if (!columnScrollTimer && pageX > containerRight) { + columnScrollTimer = setInterval(scrollColumnsRight, INTERVAL_TIME); + } else if (!columnScrollTimer && pageX < viewportLeft) { + columnScrollTimer = setInterval(scrollColumnsLeft, INTERVAL_TIME); + } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { + stopAutoScroll(); + } + + const elUnder = (() => { + // Hide ghost temporarily so elementFromPoint doesn't hit it + if (dragGhost) { + dragGhost.style.display = 'none'; + } + const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + if (dragGhost) { + dragGhost.style.display = ''; + } + return el; + })(); + const overDropzone = isOverDropzone(elUnder); + if (overDropzone) { + dropzoneTargetActive = true; + toggleDropzoneHoverClass(elUnder, true); + } else { + dropzoneTargetActive = false; + clearDropzoneHoverClasses(); + const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; + if (targetHeader) { + safely(() => { + reorderDraggedAgainstTarget(targetHeader, clientX); + }); + } + } + } + }; + + const cleanupPointerListeners = () => { + document.removeEventListener('mousemove', onPointerMove as EventListener); + document.removeEventListener('mouseup', onPointerUp as EventListener); + document.removeEventListener('touchmove', onPointerMove as EventListener); + document.removeEventListener('touchend', onPointerUp as EventListener); + document.removeEventListener('touchcancel', onPointerUp as EventListener); + }; + + const onPointerUp = (e: MouseEvent | TouchEvent) => { + cleanupPointerListeners(); + const draggedHeader = draggedEl; + draggedHeader?.classList.remove(dragActiveClass); + stopAutoScroll(); + + const { clientX, clientY } = getPointerPos(e); + let droppedOnDropzone = dropzoneTargetActive; + safely( + () => { + if (!droppedOnDropzone) { + const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + if (el?.closest?.(dropzoneSelector)) { + droppedOnDropzone = true; + } + } + }, + () => { + droppedOnDropzone = false; + } + ); + const originalParentNode = originalParent; + const originalSiblingNode = originalNextSibling; + if (droppedOnDropzone && draggedHeader && originalParentNode) { + safely(() => { + originalParentNode.insertBefore(draggedHeader, originalSiblingNode as Node | null); + }); + } + const reorderedIds = getColumnIds(headerLeft).concat(getColumnIds(headerRight)); + if (pointerDragCommitted && droppedOnDropzone && draggedHeader) { + options.onDrop?.(draggedHeader, e, draggedColumnId); + } else if (pointerDragCommitted) { + options.onDragEnd(reorderedIds); + } + resetDragState(); + // Re-enable text selection after drag completes + document.body.style.userSelect = ''; + }; + + for (const parent of [headerLeft, headerRight]) { + parent.addEventListener('dragstart', onStart as EventListener); + parent.addEventListener('dragover', onDragOver as EventListener); + parent.addEventListener('dragend', onDragEnd as EventListener); + parent.addEventListener('drop', onHeaderDrop as EventListener); + if (isFfLinux) { + // Mouse-based fallback for Firefox on Linux + parent.addEventListener('mousedown', onStart as EventListener, true); + } + // Touch fallback for all platforms (touch screens don't fire HTML5 drag events) + parent.addEventListener('touchstart', onStart as EventListener, { passive: false }); + } + + return { + destroy() { + for (const parent of [headerLeft, headerRight]) { + parent.removeEventListener('dragstart', onStart as EventListener); + parent.removeEventListener('dragover', onDragOver as EventListener); + parent.removeEventListener('dragend', onDragEnd as EventListener); + parent.removeEventListener('drop', onHeaderDrop as EventListener); + if (isFfLinux) { + parent.removeEventListener('mousedown', onStart as EventListener, true); + } + parent.removeEventListener('touchstart', onStart as EventListener); + } + document.removeEventListener('drag', autoScrollHandler as EventListener); + cleanupPointerListeners(); + document.removeEventListener('dragenter', onDropzoneDragEnter as EventListener); + document.removeEventListener('dragleave', onDropzoneDragLeave as EventListener); + stopAutoScroll(); + resetDragState(); + [headerLeft, headerRight].forEach((parent) => + Array.from(parent.querySelectorAll(draggableSelector)).forEach((el) => { + el.draggable = false; + el.classList.remove(dragActiveClass); + }) + ); + }, + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Dropzone pill drag (used by DraggableGrouping to reorder group pills) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Sets up drag-and-drop for reordering group pills inside a dropzone, plus + * accepting column-header drops that create new group pills. + * + * All Firefox+Linux detection and mouse-based fallback are handled internally; + * callers do not need to know about the browser quirk. + * + * @returns `{ destroy }` – call to remove all listeners. + */ +export function setupDropzonePillDrag(options: DropzonePillDragOption): { destroy: () => void } { + const { dropzoneElm } = options; + const itemSelector = options.itemSelector ?? '.slick-dropped-grouping'; + const draggingCssClass = options.draggingCssClass ?? ''; + const DRAG_THRESHOLD = 5; // pixels before we consider it a drag, not a click + + const userAgent = (typeof window !== 'undefined' ? window.navigator : navigator)?.userAgent ?? ''; + const isFfLinux = /firefox/i.test(userAgent) && /linux/i.test(userAgent); + + let draggedPill: HTMLElement | null = null; + let fallbackActive = false; + let dragStartX: number | null = null; + let dragStartY: number | null = null; + + // ── Native pill drag ────────────────────────────────────────────────────── + + const onDragStart = (e: DragEvent) => { + const pill = (e.target as HTMLElement).closest(itemSelector); + if (pill) { + draggedPill = pill; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + if (typeof e.dataTransfer.setData === 'function') { + e.dataTransfer.setData('text/plain', pill.dataset.id ?? ''); + } + // Explicit drag image avoids Firefox+Linux ghost rendering issues + if (typeof e.dataTransfer.setDragImage === 'function') { + const rect = pill.getBoundingClientRect(); + e.dataTransfer.setDragImage(pill, e.clientX - rect.left, e.clientY - rect.top); + } + } + } + }; + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + const target = (e.target as HTMLElement).closest(itemSelector); + if (draggedPill && target && target !== draggedPill) { + const rect = target.getBoundingClientRect(); + dropzoneElm.insertBefore(draggedPill, e.clientX < rect.left + rect.width / 2 ? target : target.nextSibling); + } + }; + + const onDragEnd = () => { + if (draggedPill) { + const currentPill = draggedPill; + if (draggingCssClass) { + currentPill.classList.remove(draggingCssClass); + } + draggedPill = null; + fallbackActive = false; + options.onPillDragEnd?.(currentPill); + } + }; + + // ── Column-header drop visual feedback & acceptance ─────────────────────── + + const onDragEnter = (e: DragEvent) => { + if (!draggedPill) { + options.onColumnDragEnter?.(e); + } + e.preventDefault(); + }; + + const onDragLeave = (e: DragEvent) => { + if (!draggedPill) { + options.onColumnDragLeave?.(e); + } + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + if (!draggedPill) { + const columnDataId = e.dataTransfer?.getData('text/plain'); + if (columnDataId) { + options.onColumnDrop?.(columnDataId, e); + } + } + }; + + // Firefox/Linux native drag is broken; touch screens never fire HTML5 drag events. + // Both cases share onPointerDown/Move/Up – coordinates come from a tiny helper. + + const onPointerDown = (e: MouseEvent | TouchEvent) => { + const pill = (e.target as HTMLElement).closest(itemSelector); + if (pill) { + // Track start position for drag threshold + dragStartX = getPointerPos(e).clientX; + dragStartY = getPointerPos(e).clientY; + draggedPill = pill; + // Add visual feedback immediately for pills (no menu conflict) + if (draggingCssClass) { + pill.classList.add(draggingCssClass); + } + // Disable text selection during drag + document.body.style.userSelect = 'none'; + if ('touches' in e) { + document.addEventListener('touchmove', onPointerMove as EventListener, { passive: false }); + document.addEventListener('touchend', onPointerUp as EventListener); + document.addEventListener('touchcancel', onPointerUp as EventListener); + } else { + document.addEventListener('mousemove', onPointerMove as EventListener); + document.addEventListener('mouseup', onPointerUp as EventListener); + } + } + }; + + const onPointerMove = (e: MouseEvent | TouchEvent) => { + if (draggedPill && dragStartX != null && dragStartY != null) { + const { clientX, clientY } = getPointerPos(e); + + // Check if we've exceeded the drag threshold + if (!fallbackActive) { + const deltaX = Math.abs(clientX - dragStartX); + const deltaY = Math.abs(clientY - dragStartY); + if (deltaX < DRAG_THRESHOLD && deltaY < DRAG_THRESHOLD) { + // Haven't moved far enough yet - don't commit to drag + return; + } + // Threshold exceeded - now commit to drag by preventing default + e.preventDefault(); + fallbackActive = true; + } + + // Now handle the actual drag movement + if (fallbackActive) { + e.preventDefault(); + const target = (document.elementFromPoint(clientX, clientY) as HTMLElement | null)?.closest(itemSelector); + if (target && target !== draggedPill && dropzoneElm.contains(target)) { + const rect = target.getBoundingClientRect(); + const insertBefore = clientX < rect.left + rect.width / 2; + const insertTarget = insertBefore ? target : target.nextSibling; + if (draggedPill.parentElement === dropzoneElm && insertTarget !== draggedPill) { + dropzoneElm.insertBefore(draggedPill, insertTarget); + } + } + } + } + }; + + const cleanupPointerListeners = () => { + document.removeEventListener('mousemove', onPointerMove as EventListener); + document.removeEventListener('mouseup', onPointerUp as EventListener); + document.removeEventListener('touchmove', onPointerMove as EventListener); + document.removeEventListener('touchend', onPointerUp as EventListener); + document.removeEventListener('touchcancel', onPointerUp as EventListener); + }; + + const onPointerUp = () => { + cleanupPointerListeners(); + const currentPill = draggedPill; + if (currentPill && draggingCssClass) { + currentPill.classList.remove(draggingCssClass); + } + draggedPill = null; + dragStartX = null; + dragStartY = null; + fallbackActive = false; + // Re-enable text selection after drag completes + document.body.style.userSelect = ''; + if (currentPill) { + options.onPillDragEnd?.(currentPill); + } + }; + + // ── Register listeners ──────────────────────────────────────────────────── + + dropzoneElm.addEventListener('dragstart', onDragStart as EventListener); + dropzoneElm.addEventListener('dragover', onDragOver as EventListener); + dropzoneElm.addEventListener('dragend', onDragEnd); + dropzoneElm.addEventListener('dragenter', onDragEnter as EventListener); + dropzoneElm.addEventListener('dragleave', onDragLeave as EventListener); + dropzoneElm.addEventListener('drop', onDrop as EventListener); + + if (isFfLinux) { + dropzoneElm.addEventListener('mousedown', onPointerDown as EventListener); + } + // Touch support for pill reordering (all platforms) + dropzoneElm.addEventListener('touchstart', onPointerDown as EventListener, { passive: false }); + + return { + destroy() { + dropzoneElm.removeEventListener('dragstart', onDragStart as EventListener); + dropzoneElm.removeEventListener('dragover', onDragOver as EventListener); + dropzoneElm.removeEventListener('dragend', onDragEnd); + dropzoneElm.removeEventListener('dragenter', onDragEnter as EventListener); + dropzoneElm.removeEventListener('dragleave', onDragLeave as EventListener); + dropzoneElm.removeEventListener('drop', onDrop as EventListener); + dropzoneElm.removeEventListener('touchstart', onPointerDown as EventListener); + if (isFfLinux) { + dropzoneElm.removeEventListener('mousedown', onPointerDown as EventListener); + } + cleanupPointerListeners(); + draggedPill = null; + dragStartX = null; + dragStartY = null; + fallbackActive = false; + }, + }; +} diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index cc786240a9..cef6bdae78 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -14,8 +14,6 @@ import { queueMicrotaskPolyfill, type CSSStyleDeclarationWritable, } from '@slickgrid-universal/utils'; -import type { Options as SortableOptions } from 'sortablejs'; -import Sortable from 'sortablejs/modular/sortable.core.esm.js'; import type { TrustedHTML } from 'trusted-types/lib'; import type { SelectionModel } from '../enums/index.js'; import { copyCellToClipboard } from '../formatters/formatterUtilities.js'; @@ -91,6 +89,7 @@ import type { SingleColumnSort, SlickPlugin, } from '../interfaces/index.js'; +import { setupColumnReorderDrag } from './slickColumnReorderDrag.js'; import { preClickClassName, SlickDragExtendHandle, @@ -530,8 +529,7 @@ export class SlickGrid = Column, O e protected slickDraggableInstance: InteractionBase | null = null; protected slickMouseWheelInstances: Array = []; protected slickResizableInstances: Array = []; - protected sortableSideLeftInstance?: ReturnType; - protected sortableSideRightInstance?: ReturnType; + protected _columnReorderDrag?: { destroy: () => void }; protected _pubSubService?: BasePubSub; /** @@ -2139,109 +2137,54 @@ export class SlickGrid = Column, O e } protected setupColumnReorder(): void { - this.sortableSideLeftInstance?.destroy(); - this.sortableSideRightInstance?.destroy(); + this._columnReorderDrag?.destroy(); - let columnScrollTimer: any; - - // add/remove extra scroll padding for calculation - const scrollColumnsRight = () => (this._viewportScrollContainerX.scrollLeft += 10); - const scrollColumnsLeft = () => (this._viewportScrollContainerX.scrollLeft -= 10); - const stopAutoScroll = () => { - clearInterval(columnScrollTimer); - columnScrollTimer = undefined; - }; let prevColumnIds: Array = []; - let columnMap: Map; - let canDragScroll = false; - - // fires on document during native drag; also bind 'mousemove' for SortableJS forceFallback mode - const autoScrollHandler = (e: DragEvent | MouseEvent) => { - const { clientX, clientY, pageX } = e as MouseEvent; - if (clientX && clientY && canDragScroll) { - const containerOffset = getOffset(this._container); - const viewportLeft = getOffset(this._viewportScrollContainerX).left; - const containerRight = containerOffset.left + this._container.clientWidth; - if (!columnScrollTimer && pageX > containerRight) { - columnScrollTimer = setInterval(scrollColumnsRight, 100); - } else if (!columnScrollTimer && pageX < viewportLeft) { - columnScrollTimer = setInterval(scrollColumnsLeft, 100); - } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { - stopAutoScroll(); - } - } - }; - - const sortableOptions = { - animation: 50, - direction: 'horizontal', - ghostClass: 'slick-sortable-placeholder', - draggable: '.slick-header-column', - dragoverBubble: false, - // Fixes broken Firefox-Linux dragging - forceFallback: /firefox/i.test(navigator.userAgent) && /linux/i.test(navigator.userAgent), - // allow column to be resized even when they are not orderable - preventOnFilter: false, - revertClone: true, - // use built-in SortableJS proximity scroll for non-frozen grids; frozen grids need custom scroll (wrong pane would be scrolled) - scroll: !this.hasFrozenColumns(), - // lock unorderable columns by using a combo of filter + onMove - filter: `.${this._options.unorderableColumnCssClass}`, - onMove: (event) => { - return !event.related.classList.contains(this._options.unorderableColumnCssClass as string); - }, - onStart: (e) => { - e.item.classList.add('slick-header-column-active'); - // only right-section columns (non-frozen) should auto-scroll; use contains() since - // _headerR has `left:-1000px` which breaks offset-based comparisons - canDragScroll = !this.hasFrozenColumns() || this._headerR.contains(e.item); - - // bind 'drag' for native HTML5 drag and 'mousemove' for SortableJS forceFallback - this._bindingEventService.bind(document, 'drag', autoScrollHandler as EventListener, {}, 'colreorder'); - this._bindingEventService.bind(document, 'mousemove', autoScrollHandler as EventListener, {}, 'colreorder'); - + let columnMap: Map | undefined; + + this._columnReorderDrag = setupColumnReorderDrag({ + headerLeft: this._headerL, + headerRight: this._headerR, + container: this._container, + viewportScrollContainerX: this._viewportScrollContainerX, + hasFrozenColumns: () => this.hasFrozenColumns(), + draggableSelector: '.slick-header-column', + dragActiveClass: 'slick-header-column-active', + unorderableColumnCssClass: this._options.unorderableColumnCssClass, + onDragStart: () => { prevColumnIds = this.columns.map((c) => c.id); - - // Create a map to track original column positions and hidden state - columnMap = new Map(); - this.columns.forEach((col, idx) => { - columnMap.set(col.id, { index: idx, hidden: !!col.hidden, column: col }); - }); + const map = new Map(); + this.columns.forEach((col, idx) => + map.set(String(col.id), { index: idx, hidden: !!col.hidden, reorderable: col.reorderable !== false, column: col }) + ); + columnMap = map; }, - onEnd: (e) => { - e.item.classList.remove('slick-header-column-active'); - stopAutoScroll(); - this._bindingEventService.unbindAll('colreorder'); - const prevScrollLeft = this.scrollLeft; - - if (!this.getEditorLock()?.commitCurrentEdit()) { + onDragEnd: (reorderedIds) => { + // onDragStart never ran (drag started outside a column) or when editing a cell then cancel the reorder operation + if (!columnMap || !this.getEditorLock()?.commitCurrentEdit()) { return; } - let reorderedIds = this.sortableSideLeftInstance?.toArray() ?? []; - reorderedIds = reorderedIds.concat(this.sortableSideRightInstance?.toArray() ?? []); - - const reorderedColumns: C[] = []; - for (let i = 0; i < reorderedIds.length; i++) { - reorderedColumns.push(this.columns[this.getColumnIndex(reorderedIds[i])]); - } + const prevScrollLeft = this.scrollLeft; + const reorderedColumns: C[] = reorderedIds.map((id) => columnMap?.get(id)?.column).filter((column): column is C => !!column); + const reorderedIdSet = new Set(reorderedIds); - // Reconstruct final column array: insert hidden columns at their original indices + // Reconstruct final column array while preserving hidden/non-draggable columns at their original indices. const finalColumns: C[] = []; let visibleIdx = 0; for (let i = 0; i < this.columns.length; i++) { - const colInfo = columnMap.get(this.columns[i].id); + const colInfo = columnMap.get(String(this.columns[i].id)); if (colInfo?.hidden) { - // Hidden column: insert at its original position finalColumns.push(colInfo.column); + } else if (colInfo?.reorderable && reorderedIdSet.has(String(this.columns[i].id))) { + finalColumns.push(reorderedColumns[visibleIdx++] ?? colInfo!.column); } else { - // Visible column: use reordered position - finalColumns.push(reorderedColumns[visibleIdx++]); + finalColumns.push(colInfo!.column); } } - e.stopPropagation(); - if (!this.arrayEquals(prevColumnIds, reorderedIds)) { + const finalColumnIds = finalColumns.map((col) => col.id); + if (!this.arrayEquals(prevColumnIds, finalColumnIds)) { this.setColumns(finalColumns); // reapply previous scroll position since it might move back to x=0 after calling `setColumns()` (especially when `frozenColumn` is set) this.scrollToX(prevScrollLeft); @@ -2252,10 +2195,7 @@ export class SlickGrid = Column, O e this.setFocus(); // refocus on active cell } }, - } as SortableOptions; - - this.sortableSideLeftInstance = Sortable.create(this._headerL, sortableOptions); - this.sortableSideRightInstance = Sortable.create(this._headerR, sortableOptions); + }); } protected getHeaderChildren(): HTMLElement[] { @@ -3077,12 +3017,7 @@ export class SlickGrid = Column, O e this.unregisterPlugin(this.plugins[i]); } - if (this.sortableSideRightInstance?.el && typeof this.sortableSideRightInstance?.destroy === 'function') { - this.sortableSideRightInstance.destroy(); - } - if (this.sortableSideLeftInstance?.el && typeof this.sortableSideLeftInstance?.destroy === 'function') { - this.sortableSideLeftInstance.destroy(); - } + this._columnReorderDrag?.destroy(); this._boundAncestors.length = 0; // reset array diff --git a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts index 7a7f1e4f4b..add57cf8e7 100644 --- a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts +++ b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts @@ -1,6 +1,5 @@ import { EventPubSubService } from '@slickgrid-universal/event-pub-sub'; import { createDomElement, deepCopy } from '@slickgrid-universal/utils'; -import { type SortableOptions } from 'sortablejs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TranslateServiceStub } from '../../../../../test/translateServiceStub.js'; import { Aggregators } from '../../aggregators/aggregators.index.js'; @@ -12,25 +11,6 @@ import { BackendUtilityService } from '../../services/backendUtility.service.js' import { SharedService } from '../../services/shared.service.js'; import { SlickDraggableGrouping } from '../slickDraggableGrouping.js'; -vi.mock('sortablejs', () => ({ - default: { - el: undefined, - options: {} as SortableOptions, - constructor: vi.fn(), - create: (el: HTMLElement, options: SortableOptions) => { - return { - el, - options, - destroy: vi.fn(), - toArray: vi.fn(), - }; - }, - utils: { - clone: (el: HTMLElement) => el.cloneNode(true), - }, - }, -})); - const GRID_UID = 'slickgrid12345'; const addonOptions: DraggableGroupingOption = { @@ -241,6 +221,27 @@ describe('Draggable Grouping Plugin', () => { }); describe('setupColumnReorder definition', () => { + /** Dispatch dragstart on a header column (bubbles up to the parent handler) */ + const fireDragStartOnHeader = (el: HTMLElement) => { + const evt = new Event('dragstart', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(evt, 'dataTransfer', { value: { effectAllowed: '', setDragImage: vi.fn(), setData: vi.fn() } }); + Object.defineProperty(evt, 'offsetX', { value: 10 }); + Object.defineProperty(evt, 'offsetY', { value: 5 }); + el.dispatchEvent(evt); + }; + + /** Dispatch dragend on a header column */ + const fireDragEndOnHeader = (el: HTMLElement) => { + el.dispatchEvent(new Event('dragend', { bubbles: true, cancelable: true })); + }; + + /** Simulate dropping a column header into the grouping dropzone */ + const fireDropOnDropzone = (dropzone: HTMLElement, columnId: string) => { + const evt = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(evt, 'dataTransfer', { value: { getData: (_type: string) => columnId } }); + dropzone.dispatchEvent(evt); + }; + let mockDivPaneContainer1: HTMLDivElement; let headerColumnDiv1: HTMLDivElement; let headerColumnDiv2: HTMLDivElement; @@ -283,32 +284,18 @@ describe('Draggable Grouping Plugin', () => { vi.clearAllMocks(); }); - it('should execute the "onStart" and "onAdd" callbacks of Sortable and expect css classes to be updated', () => { + it('should execute the "onStart" and "onAdd" callbacks and expect css classes to be updated', () => { plugin.init(gridStub, { ...addonOptions }); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - vi.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age', 'medals']); - - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); let groupByRemoveElm = preHeaderDiv.querySelector('.slick-groupby-remove') as HTMLDivElement; const groupByRemoveImageElm = document.querySelector('.slick-groupby-remove-icon') as HTMLDivElement; let placeholderElm = preHeaderDiv.querySelector('.slick-draggable-dropzone-placeholder') as HTMLDivElement; - expect(fn.sortableLeftInstance).toEqual(plugin.sortableLeftInstance); - expect(fn.sortableRightInstance).toEqual(plugin.sortableRightInstance); - expect(fn.sortableLeftInstance.destroy).toBeTruthy(); expect(groupByRemoveElm).toBeTruthy(); expect(groupByRemoveImageElm).toBeTruthy(); @@ -324,27 +311,14 @@ describe('Draggable Grouping Plugin', () => { expect(toggleAllElm.style.display).toBe('none'); }); - it('should execute the "onEnd" callback of Sortable and expect css classes to be updated', () => { + it('should execute the "onEnd" callback and expect css classes to be updated', () => { plugin.init(gridStub, { ...addonOptions }); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - vi.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue([]); - - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); - const endEvent = new CustomEvent('end'); - Object.defineProperty(endEvent, 'item', { writable: true, configurable: true, value: headerColumnDiv3 }); - fn.sortableLeftInstance.options.onEnd!(endEvent as any); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); + fireDragEndOnHeader(onStartItem); const placeholderElm = preHeaderDiv.querySelector('.slick-draggable-dropzone-placeholder') as HTMLDivElement; const dropGroupingElm = preHeaderDiv.querySelector('.slick-dropped-grouping') as HTMLDivElement; @@ -352,27 +326,15 @@ describe('Draggable Grouping Plugin', () => { expect(dropGroupingElm.style.display).toBe('flex'); }); - it('should execute the "onEnd" callback of Sortable and expect sortable to be cancelled', () => { + it('should execute the "onEnd" callback and expect reorder to be cancelled (EditorLock fails)', () => { plugin.init(gridStub, { ...addonOptions }); plugin.setAddonOptions({ deleteIconCssClass: 'mdi mdi-close color-danger' }); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); - const endEvent = new CustomEvent('end'); - Object.defineProperty(endEvent, 'item', { writable: true, configurable: true, value: headerColumnDiv3 }); - fn.sortableLeftInstance.options.onEnd!(endEvent as any); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); + fireDragEndOnHeader(onStartItem); const groupByRemoveElm = document.querySelector('.slick-groupby-remove.mdi-close') as HTMLDivElement; expect(groupByRemoveElm).toBeTruthy(); @@ -385,92 +347,91 @@ describe('Draggable Grouping Plugin', () => { it('should clear grouping and expect placeholder to be displayed when calling "onEnd" callback', () => { plugin.init(gridStub, { ...addonOptions }); plugin.setAddonOptions({ deleteIconCssClass: 'mdi mdi-close color-danger' }); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); plugin.clearDroppedGroups(); - const endEvent = new CustomEvent('end'); - Object.defineProperty(endEvent, 'item', { writable: true, configurable: true, value: headerColumnDiv3 }); - fn.sortableLeftInstance.options.onEnd!(endEvent as any); + fireDragEndOnHeader(onStartItem); const draggablePlaceholderElm = dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder') as HTMLDivElement; expect(draggablePlaceholderElm.style.display).toEqual('inline-block'); }); - it('should execute the "onEnd" callback of Sortable and expect css classes to be updated', () => { + it('should execute the "onEnd" callback and expect setColumns to be called when EditorLock passes', () => { plugin.init(gridStub, { ...addonOptions }); plugin.setColumns(mockColumns); plugin.setAddonOptions({ deleteIconCssClass: 'mdi mdi-close' }); vi.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit').mockReturnValue(true); getColumnIndexSpy.mockReturnValue(2); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - vi.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age', 'medals']); - - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + // Put age and medals in header in that order so getColumnIds returns ['age','medals'] + const ageHeaderCol = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + createDomElement('div', { className: 'slick-header-column', dataset: { id: 'medals' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(ageHeaderCol); + fireDropOnDropzone(dropzoneElm, 'age'); const groupByRemoveElm = document.querySelector('.slick-groupby-remove.mdi-close'); expect(groupByRemoveElm).toBeTruthy(); - const endEvent = new CustomEvent('end'); - Object.defineProperty(endEvent, 'item', { writable: true, configurable: true, value: headerColumnDiv3 }); - fn.sortableLeftInstance.options.onEnd!(endEvent as any); + fireDragEndOnHeader(ageHeaderCol); expect(setColumnsSpy).toHaveBeenCalledWith([mockColumns[2], mockColumns[2]]); expect(setColumnResizeSpy).toHaveBeenCalled(); expect(triggerSpy).toHaveBeenCalledWith(gridStub.onColumnsReordered, { grid: gridStub, impactedColumns: expect.arrayContaining([mockColumns[2]]) }); }); + it('should execute setupColumnReorder onDrop callback when header drag ends over dropzone', () => { + plugin.init(gridStub, { ...addonOptions }); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const headerCol = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + + // Create a dropped grouping and toggle all so onDrop callback executes its full UI branch (lines 311-326) + fireDropOnDropzone(dropzoneElm, 'age'); + + // Simulate drop over grouping dropzone at dragend so setupColumnReorderDrag calls onDrop callback. + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => dropzoneElm as Element), + }); + + fireDragStartOnHeader(headerCol); + const dragEndEvt = new Event('dragend', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(dragEndEvt, 'clientX', { value: 25 }); + Object.defineProperty(dragEndEvt, 'clientY', { value: 12 }); + headerCol.dispatchEvent(dragEndEvt); + + const draggablePlaceholderElm = dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder') as HTMLDivElement; + const droppedGroupingElm = dropzoneElm.querySelector('.slick-dropped-grouping') as HTMLDivElement; + const toggleAllElm = dropzoneElm.querySelector('.slick-group-toggle-all') as HTMLDivElement; + + expect(draggablePlaceholderElm.style.display).toBe('none'); + expect(droppedGroupingElm.style.display).toBe('flex'); + expect(toggleAllElm?.style.display).toBe('inline-flex'); + }); + it('should drag over dropzone and expect hover css class be added and removed when dragging outside of dropzone', () => { plugin.init(gridStub, { ...addonOptions }); plugin.setAddonOptions({ deleteIconCssClass: 'mdi mdi-close color-danger' }); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); - const endEvent = new CustomEvent('end'); - Object.defineProperty(endEvent, 'item', { writable: true, configurable: true, value: headerColumnDiv3 }); - fn.sortableLeftInstance.options.onEnd!(endEvent as any); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); + fireDragEndOnHeader(onStartItem); const dropzonePlaceholderElm = dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder'); const dragoverEvent = new CustomEvent('dragover', { bubbles: true, detail: {} }); dropzonePlaceholderElm?.dispatchEvent(dragoverEvent); - const dragenterEvent = new CustomEvent('dragenter', { bubbles: true, detail: {} }); + const dragenterEvent = new CustomEvent('dragenter', { bubbles: true, detail: {} }) as unknown as DragEvent; + Object.defineProperty(dragenterEvent, 'dataTransfer', { + configurable: true, + value: { types: ['text/plain'] }, + }); dropzonePlaceholderElm?.dispatchEvent(dragenterEvent); expect(dropzoneElm.classList.contains('slick-dropzone-hover')).toBeTruthy(); @@ -479,6 +440,190 @@ describe('Draggable Grouping Plugin', () => { expect(dropzoneElm.classList.contains('slick-dropzone-hover')).toBeFalsy(); }); + it('should ignore placeholder dragenter when drag payload has no types', () => { + plugin.init(gridStub, { ...addonOptions }); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const dropzonePlaceholderElm = dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder') as HTMLDivElement; + const dragenterEvent = new CustomEvent('dragenter', { bubbles: true, cancelable: true }) as unknown as DragEvent; + Object.defineProperty(dragenterEvent, 'dataTransfer', { + configurable: true, + value: { types: [] }, + }); + + dropzonePlaceholderElm.dispatchEvent(dragenterEvent); + + expect(dropzoneElm.classList.contains('slick-dropzone-hover')).toBeFalsy(); + }); + + it('should ignore dropzone dragenter when drag payload has no types', () => { + plugin.init(gridStub, { ...addonOptions }); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const dragenterEvent = new CustomEvent('dragenter', { bubbles: true, cancelable: true }) as unknown as DragEvent; + Object.defineProperty(dragenterEvent, 'dataTransfer', { + configurable: true, + value: { types: [] }, + }); + + dropzoneElm.dispatchEvent(dragenterEvent); + + expect(dropzoneElm.classList.contains('slick-dropzone-hover')).toBeFalsy(); + }); + + it('should call setData and setDragImage with rect-relative coords on pill dragstart (Firefox/Linux fix)', () => { + plugin.init(gridStub, { ...addonOptions }); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const firstPill = createDomElement('div', { className: 'slick-dropped-grouping', dataset: { id: 'age' } }, dropzoneElm); + const setDataSpy = vi.fn(); + const setDragImageSpy = vi.fn(); + + vi.spyOn(firstPill, 'getBoundingClientRect').mockReturnValue({ + left: 20, + top: 5, + right: 100, + bottom: 25, + width: 80, + height: 20, + x: 20, + y: 5, + toJSON: () => ({}), + } as DOMRect); + + const pillDragStart = new Event('dragstart', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(pillDragStart, 'dataTransfer', { + value: { effectAllowed: '', setData: setDataSpy, setDragImage: setDragImageSpy }, + }); + Object.defineProperty(pillDragStart, 'clientX', { value: 30 }); + Object.defineProperty(pillDragStart, 'clientY', { value: 12 }); + firstPill.dispatchEvent(pillDragStart); + + // Firefox requires setData() to initiate drag + expect(setDataSpy).toHaveBeenCalledWith('text/plain', 'age'); + // Ghost anchored relative to pill rect, not to an arbitrary child element + expect(setDragImageSpy).toHaveBeenCalledWith(firstPill, 10, 7); // clientX-rect.left=10, clientY-rect.top=7 + }); + + it('should reorder dropped grouping pills when dragging one pill over another', () => { + plugin.init(gridStub, { ...addonOptions }); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const firstPill = createDomElement('div', { className: 'slick-dropped-grouping', dataset: { id: 'age' } }, dropzoneElm); + const secondPill = createDomElement('div', { className: 'slick-dropped-grouping', dataset: { id: 'medals' } }, dropzoneElm); + + const pillDragStart = new Event('dragstart', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(pillDragStart, 'dataTransfer', { value: { effectAllowed: '' } }); + firstPill.dispatchEvent(pillDragStart); + + vi.spyOn(secondPill, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + const pillDragOver = new Event('dragover', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(pillDragOver, 'clientX', { value: 139 }); + secondPill.dispatchEvent(pillDragOver); + + expect(Array.from(dropzoneElm.querySelectorAll('.slick-dropped-grouping')).map((el) => (el as HTMLElement).dataset.id)).toEqual(['medals', 'age']); + }); + + it('should update grouped column order on Firefox fallback mouse up after swapping pills', () => { + Object.defineProperty(window.navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 Firefox/128.0 Linux x86_64', + }); + + plugin.init(gridStub, { ...addonOptions }); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const ageHeader = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + const medalsHeader = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'medals' } }, mockHeaderLeftDiv1); + (plugin as any).handleGroupByDrop(dropzoneElm, ageHeader); + (plugin as any).handleGroupByDrop(dropzoneElm, medalsHeader); + + const firstPill = dropzoneElm.querySelector('.slick-dropped-grouping') as HTMLElement; + const secondPill = dropzoneElm.querySelectorAll('.slick-dropped-grouping')[1] as HTMLElement; + expect(plugin.columnsGroupBy.map((col) => col.id)).toEqual(['age', 'medals']); + + vi.spyOn(secondPill, 'getBoundingClientRect').mockReturnValue({ + left: 100, + width: 40, + right: 140, + top: 0, + bottom: 0, + x: 100, + y: 0, + height: 20, + toJSON: () => ({}), + } as DOMRect); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => secondPill), + }); + + const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + Object.defineProperty(mouseDown, 'target', { value: firstPill }); + firstPill.dispatchEvent(mouseDown); + + const mouseMove = new MouseEvent('mousemove', { bubbles: true, cancelable: true }); + Object.defineProperty(mouseMove, 'clientX', { value: 139 }); + Object.defineProperty(mouseMove, 'clientY', { value: 10 }); + document.dispatchEvent(mouseMove); + + const mouseUp = new MouseEvent('mouseup', { bubbles: true, cancelable: true }); + Object.defineProperty(mouseUp, 'clientX', { value: 139 }); + Object.defineProperty(mouseUp, 'clientY', { value: 10 }); + document.dispatchEvent(mouseUp); + + expect(Array.from(dropzoneElm.querySelectorAll('.slick-dropped-grouping')).map((el) => (el as HTMLElement).dataset.id)).toEqual(['medals', 'age']); + expect(plugin.columnsGroupBy.map((col) => col.id)).toEqual(['medals', 'age']); + }); + + it('should reorder dropped grouping pills with a mouse fallback on Firefox/Linux', () => { + const originalUserAgent = navigator.userAgent; + Object.defineProperty(window.navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 Firefox/128.0 Linux x86_64', + }); + + plugin.init(gridStub, { ...addonOptions }); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + + const firstPill = createDomElement('div', { className: 'slick-dropped-grouping', dataset: { id: 'age' } }, dropzoneElm); + const secondPill = createDomElement('div', { className: 'slick-dropped-grouping', dataset: { id: 'medals' } }, dropzoneElm); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => secondPill as Element), + }); + + const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + Object.defineProperty(mouseDown, 'clientX', { value: 10 }); + Object.defineProperty(mouseDown, 'clientY', { value: 10 }); + firstPill.dispatchEvent(mouseDown); + + const mouseMove = new MouseEvent('mousemove', { bubbles: true, cancelable: true }); + Object.defineProperty(mouseMove, 'clientX', { value: 120 }); + Object.defineProperty(mouseMove, 'clientY', { value: 10 }); + document.dispatchEvent(mouseMove); + + const mouseUp = new MouseEvent('mouseup', { bubbles: true, cancelable: true }); + Object.defineProperty(mouseUp, 'clientX', { value: 120 }); + Object.defineProperty(mouseUp, 'clientY', { value: 10 }); + document.dispatchEvent(mouseUp); + + expect(Array.from(dropzoneElm.querySelectorAll('.slick-dropped-grouping')).map((el) => (el as HTMLElement).dataset.id)).toEqual(['medals', 'age']); + + Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: originalUserAgent }); + }); + describe('setupColumnDropbox method', () => { describe('setupColumnDropbox update & toggler click event', () => { let groupChangedSpy: any; @@ -509,22 +654,15 @@ describe('Draggable Grouping Plugin', () => { plugin.setAddonOptions({ deleteIconCssClass: 'mdi mdi-close' }); vi.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit').mockReturnValue(false); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - vi.spyOn(plugin.droppableInstance!, 'toArray').mockReturnValue(['age', 'medals']); - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); - plugin.droppableInstance?.options.onUpdate!({ item: mockDivPaneContainer1, clone: mockDivPaneContainer1.cloneNode(true) } as any); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); + // Simulate pill reorder via native drag on group pill + const pillDragEvt = new Event('dragstart', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(pillDragEvt, 'dataTransfer', { value: { effectAllowed: '', setData: vi.fn() } }); + mockHeaderColumnDiv1.dispatchEvent(pillDragEvt); + mockHeaderColumnDiv1.dispatchEvent(new Event('dragend', { bubbles: true })); }); afterEach(() => { @@ -560,25 +698,95 @@ describe('Draggable Grouping Plugin', () => { it('should call sortable "update" from setupColumnDropbox and expect "updateGroupBy" to be called with a sort-group', () => { expect(plugin.dropboxElement).toEqual(dropzoneElm); expect(plugin.columnsGroupBy.length).toBeGreaterThan(0); - const onGroupChangedArgs = { - caller: 'sort-group', - groupColumns: [ - { - aggregators: expect.any(Array), - formatter: mockColumns[2].grouping!.formatter, - getter: 'age', - collapsed: false, - sortAsc: true, - }, - ], - }; - expect(onGroupChangedCallbackSpy).toHaveBeenCalledWith(expect.anything(), onGroupChangedArgs); - expect(groupChangedSpy).toHaveBeenCalledWith(onGroupChangedArgs); + expect(onGroupChangedCallbackSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + caller: 'sort-group', + groupColumns: expect.arrayContaining([ + expect.objectContaining({ + aggregators: expect.any(Array), + formatter: mockColumns[2].grouping!.formatter, + getter: 'age', + collapsed: false, + sortAsc: true, + }), + ]), + }) + ); + expect(groupChangedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + caller: 'sort-group', + groupColumns: expect.arrayContaining([expect.objectContaining({ getter: 'age' })]), + }) + ); vi.spyOn(gridStub, 'getHeaderColumn').mockReturnValue(mockHeaderColumnDiv1); plugin.setDroppedGroups('age'); }); + it('should NOT call updateGroupBy("sort-group") when pill is dragged but order is unchanged (isSameOrder)', () => { + // Start fresh: clear all pills and columnsGroupBy so they're in sync + dropzoneElm.querySelectorAll('.slick-dropped-grouping').forEach((el) => el.remove()); + const ageCol = plugin.columnsGroupBy.find((c) => String(c.id) === 'age'); + plugin.columnsGroupBy = ageCol ? [ageCol] : []; + + // Add exactly one pill that matches the single entry in columnsGroupBy + const syncedPill = document.createElement('div'); + syncedPill.className = 'slick-dropped-grouping'; + syncedPill.dataset.id = 'age'; + dropzoneElm.appendChild(syncedPill); + + // Clear spy history recorded during beforeEach setup + onGroupChangedCallbackSpy.mockClear(); + + // Fire pill dragstart+dragend without moving anything → isSameOrder should be true + const pillDragEvt = new Event('dragstart', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(pillDragEvt, 'dataTransfer', { value: { effectAllowed: '', setData: vi.fn(), setDragImage: vi.fn() } }); + syncedPill.dispatchEvent(pillDragEvt); + syncedPill.dispatchEvent(new Event('dragend', { bubbles: true })); + + // updateGroupBy('sort-group') must NOT have been called because order did not change + expect(onGroupChangedCallbackSpy).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ caller: 'sort-group' })); + }); + + it('should keep dropzone hover class on dragleave when relatedTarget is null but pointer is still over dropzone', () => { + const innerDropzoneChild = document.createElement('div'); + dropzoneElm.appendChild(innerDropzoneChild); + dropzoneElm.classList.add('slick-dropzone-hover'); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => innerDropzoneChild as Element), + }); + + const dragLeaveEvt = new CustomEvent('dragleave', { bubbles: true, cancelable: true }) as unknown as DragEvent; + Object.defineProperty(dragLeaveEvt, 'relatedTarget', { configurable: true, value: null }); + Object.defineProperty(dragLeaveEvt, 'clientX', { configurable: true, value: 25 }); + Object.defineProperty(dragLeaveEvt, 'clientY', { configurable: true, value: 25 }); + dropzoneElm.dispatchEvent(dragLeaveEvt); + + expect(dropzoneElm.classList.contains('slick-dropzone-hover')).toBeTruthy(); + }); + + it('should clear dropzone hover class on dragleave when relatedTarget is null and pointer is outside dropzone', () => { + const outsideElm = document.createElement('div'); + document.body.appendChild(outsideElm); + dropzoneElm.classList.add('slick-dropzone-hover'); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => outsideElm as Element), + }); + + const dragLeaveEvt = new CustomEvent('dragleave', { bubbles: true, cancelable: true }) as unknown as DragEvent; + Object.defineProperty(dragLeaveEvt, 'relatedTarget', { configurable: true, value: null }); + Object.defineProperty(dragLeaveEvt, 'clientX', { configurable: true, value: 300 }); + Object.defineProperty(dragLeaveEvt, 'clientY', { configurable: true, value: 300 }); + dropzoneElm.dispatchEvent(dragLeaveEvt); + + expect(dropzoneElm.classList.contains('slick-dropzone-hover')).toBeFalsy(); + }); + it('should call "clearDroppedGroups" and expect the grouping to be cleared', () => { const preHeaderElm = document.querySelector('.slick-preheader-panel') as HTMLDivElement; let dropboxPlaceholderElm = preHeaderElm.querySelector('.slick-draggable-dropzone-placeholder') as HTMLDivElement; @@ -617,6 +825,21 @@ describe('Draggable Grouping Plugin', () => { expect(dvExpandSpy).toHaveBeenCalled(); }); + it('should use Toggle All icon fallback query when click target is not the icon', () => { + const dvCollapseSpy = vi.spyOn(dataViewStub, 'collapseAllGroups'); + const toggleAllElm = document.querySelector('.slick-group-toggle-all') as HTMLDivElement; + const toggleAllIconElm = toggleAllElm.querySelector('.slick-group-toggle-all-icon') as HTMLDivElement; + const clickEvent = new Event('click'); + Object.defineProperty(clickEvent, 'target', { writable: true, configurable: true, value: toggleAllElm }); + + // click target is container, handler should fallback to currentTarget.querySelector('.slick-group-toggle-all-icon') + toggleAllElm.dispatchEvent(clickEvent); + + expect(toggleAllIconElm.classList.contains('collapsed')).toBeTruthy(); + expect(toggleAllIconElm.classList.contains('expanded')).toBeFalsy(); + expect(dvCollapseSpy).toHaveBeenCalled(); + }); + it('should clear all grouping when that action is called from Context Menu, it must be also cleared in the Draggable Grouping preheader', () => { const clearGroupSpy = vi.spyOn(plugin, 'clearDroppedGroups'); eventPubSubService.publish('onContextMenuClearGrouping'); @@ -673,30 +896,16 @@ describe('Draggable Grouping Plugin', () => { it('should not expect any sort icons displayed when "hideGroupSortIcons" is set to True', () => { plugin.init(gridStub, { ...addonOptions, hideGroupSortIcons: true }); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - vi.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age', 'medals']); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); expect(plugin.addonOptions.hideGroupSortIcons).toBe(true); const groupBySortElm = preHeaderDiv.querySelector('.slick-groupby-sort') as HTMLDivElement; const groupBySortAscIconElm = preHeaderDiv.querySelector('.slick-groupby-sort-asc-icon') as HTMLDivElement; - expect(fn.sortableLeftInstance).toEqual(plugin.sortableLeftInstance); - expect(fn.sortableRightInstance).toEqual(plugin.sortableRightInstance); - expect(fn.sortableLeftInstance.destroy).toBeTruthy(); expect(groupBySortElm).toBeFalsy(); expect(groupBySortAscIconElm).toBeFalsy(); }); @@ -706,7 +915,7 @@ describe('Draggable Grouping Plugin', () => { mockColumnsCopy[2].sortable = false; // change age column to not sortable vi.spyOn(gridStub, 'getColumns').mockReturnValueOnce(mockColumnsCopy); plugin.init(gridStub, { ...addonOptions }); - const fn = plugin.setupColumnReorder( + plugin.setupColumnReorder( gridStub, mockHeaderLeftDiv1, {}, @@ -717,11 +926,10 @@ describe('Draggable Grouping Plugin', () => { GRID_UID, triggerSpy ); - vi.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age']); - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); const groupBySortElm = preHeaderDiv.querySelector('.slick-groupby-sort') as HTMLDivElement; const groupBySortAscIconElm = preHeaderDiv.querySelector('.slick-groupby-sort-asc-icon') as HTMLDivElement; @@ -735,29 +943,14 @@ describe('Draggable Grouping Plugin', () => { it('should toggle ascending/descending order when original sort is ascending then user clicked the sorting icon twice', () => { const onGroupChangedCallbackSpy = vi.fn(); plugin.init(gridStub, { ...addonOptions, onGroupChanged: onGroupChangedCallbackSpy }); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - vi.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age', 'medals']); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); const invalidateSpy = vi.spyOn(gridStub, 'invalidate'); - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); const groupBySortElm = preHeaderDiv.querySelector('.slick-groupby-sort') as HTMLDivElement; - - expect(fn.sortableLeftInstance).toEqual(plugin.sortableLeftInstance); - expect(fn.sortableRightInstance).toEqual(plugin.sortableRightInstance); - expect(fn.sortableLeftInstance.destroy).toBeTruthy(); expect(groupBySortElm).toBeTruthy(); expect(groupBySortElm.classList.contains('slick-groupby-sort-asc-icon')).toBeTruthy(); @@ -789,29 +982,14 @@ describe('Draggable Grouping Plugin', () => { sortDescIconCssClass: 'mdi mdi-arrow-down', onGroupChanged: onGroupChangedCallbackSpy, }); - const fn = plugin.setupColumnReorder( - gridStub, - mockHeaderLeftDiv1, - {}, - setColumnsSpy, - setColumnResizeSpy, - mockColumns, - getColumnIndexSpy, - GRID_UID, - triggerSpy - ); - vi.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['age', 'medals']); + plugin.setupColumnReorder(gridStub, mockHeaderLeftDiv1, {}, setColumnsSpy, setColumnResizeSpy, mockColumns, getColumnIndexSpy, GRID_UID, triggerSpy); const invalidateSpy = vi.spyOn(gridStub, 'invalidate'); - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); + const onStartItem = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + fireDragStartOnHeader(onStartItem); + fireDropOnDropzone(dropzoneElm, 'age'); const groupBySortElm = preHeaderDiv.querySelector('.slick-groupby-sort') as HTMLDivElement; - - expect(fn.sortableLeftInstance).toEqual(plugin.sortableLeftInstance); - expect(fn.sortableRightInstance).toEqual(plugin.sortableRightInstance); - expect(fn.sortableLeftInstance.destroy).toBeTruthy(); expect(groupBySortElm).toBeTruthy(); expect(groupBySortElm.classList.contains('mdi-arrow-up')).toBeTruthy(); @@ -847,10 +1025,10 @@ describe('Draggable Grouping Plugin', () => { getColumnIndexSpy.mockReturnValueOnce(0).mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValueOnce(3).mockReturnValueOnce(4); }); - it('should execute the "onEnd" callback of Sortable and expect sortable to be cancelled', () => { + it('should execute the "onEnd" callback with frozen columns and expect setColumns to be called', () => { plugin.init(gridStub, { ...addonOptions }); plugin.setAddonOptions({ deleteIconCssClass: 'mdi mdi-close color-danger' }); - const fn = plugin.setupColumnReorder( + plugin.setupColumnReorder( gridStub, [mockHeaderLeftDiv1, mockHeaderLeftDiv2], {}, @@ -861,20 +1039,20 @@ describe('Draggable Grouping Plugin', () => { GRID_UID, triggerSpy ); - vi.spyOn(fn.sortableLeftInstance, 'toArray').mockReturnValue(['firstName', 'lastName', 'age']); - vi.spyOn(fn.sortableRightInstance, 'toArray').mockReturnValue(['gender']); + // Arrange left header: firstName, lastName, age; right header: gender + createDomElement('div', { className: 'slick-header-column', dataset: { id: 'firstName' } }, mockHeaderLeftDiv1); + createDomElement('div', { className: 'slick-header-column', dataset: { id: 'lastName' } }, mockHeaderLeftDiv1); + const ageLeftCol = createDomElement('div', { className: 'slick-header-column', dataset: { id: 'age' } }, mockHeaderLeftDiv1); + createDomElement('div', { className: 'slick-header-column', dataset: { id: 'gender' } }, mockHeaderLeftDiv2); - const onStartItem = createDomElement('div', { className: 'slick-header-column', id: `${GRID_UID}age`, dataset: { id: 'age' } }, preHeaderDiv); - fn.sortableLeftInstance!.options.onStart!({ item: onStartItem } as any); - plugin.droppableInstance!.options.onAdd!({ item: headerColumnDiv3, clone: headerColumnDiv3.cloneNode(true) } as any); - const endEvent = new CustomEvent('end'); - Object.defineProperty(endEvent, 'item', { writable: true, configurable: true, value: headerColumnDiv3 }); - fn.sortableLeftInstance.options.onEnd!(endEvent as any); + fireDragStartOnHeader(ageLeftCol); + fireDropOnDropzone(dropzoneElm, 'age'); + fireDragEndOnHeader(ageLeftCol); const groupByRemoveElm = document.querySelector('.slick-groupby-remove.mdi-close') as HTMLDivElement; expect(groupByRemoveElm).toBeTruthy(); - mockColumns.pop(); // all original columns except last column which is what we returned on sortableRightInstance + mockColumns.pop(); // remove gender (index 4); getColumnIndexSpy returns 0,1,2,3 for firstName,lastName,age,gender expect(setColumnsSpy).toHaveBeenCalledWith(mockColumns); }); }); diff --git a/packages/common/src/extensions/slickDraggableGrouping.ts b/packages/common/src/extensions/slickDraggableGrouping.ts index d349739774..742880288d 100644 --- a/packages/common/src/extensions/slickDraggableGrouping.ts +++ b/packages/common/src/extensions/slickDraggableGrouping.ts @@ -1,9 +1,8 @@ import { BindingEventService } from '@slickgrid-universal/binding'; import type { BasePubSubService, EventSubscription } from '@slickgrid-universal/event-pub-sub'; import { classNameToList, createDomElement, emptyElement, isEmptyObject } from '@slickgrid-universal/utils'; -import type { SortableEvent, Options as SortableOptions } from 'sortablejs'; -import Sortable from 'sortablejs/modular/sortable.core.esm.js'; import { SlickEvent, SlickEventData, SlickEventHandler, type SlickDataView, type SlickGrid } from '../core/index.js'; +import { setupColumnReorderDrag, setupDropzonePillDrag } from '../core/slickColumnReorderDrag.js'; import { SortDirectionNumber } from '../enums/index.js'; import type { Column, @@ -18,6 +17,12 @@ import type { SharedService } from '../services/shared.service.js'; import { sortByFieldType } from '../sortComparers/sortUtilities.js'; import type { ExtensionUtility } from './extensionUtility.js'; +const DROPZONE_CLASS = 'slick-dropzone'; +const DROPZONE_HOVER_CLASS = 'slick-dropzone-hover'; +const GROUP_TOGGLE_ALL_CLASS = 'slick-group-toggle-all'; +const GROUP_TOGGLE_ALL_ICON_CLASS = 'slick-group-toggle-all-icon'; +const DROPZONE_PLACEHOLDER_CLASS = 'slick-draggable-dropzone-placeholder'; + /** * * Draggable Grouping contributed by: Muthukumar Selconstasu @@ -47,7 +52,8 @@ export class SlickDraggableGrouping { protected _addonOptions!: DraggableGrouping; protected _bindingEventService: BindingEventService; - protected _droppableInstance?: Sortable; + protected _columnReorderDrag?: { destroy: () => void }; + protected _dropboxDrag?: { destroy: () => void }; protected _dropzoneElm!: HTMLDivElement; protected _dropzonePlaceholderElm!: HTMLDivElement; protected _eventHandler!: SlickEventHandler; @@ -57,8 +63,6 @@ export class SlickDraggableGrouping { protected _groupToggler?: HTMLDivElement; protected _isInitialized = false; protected _reorderedColumns: Column[] = []; - protected _sortableLeftInstance?: Sortable; - protected _sortableRightInstance?: Sortable; protected _subscriptions: EventSubscription[] = []; protected _defaults = { dropPlaceHolderText: 'Drop a column header here to group by the column', @@ -92,18 +96,6 @@ export class SlickDraggableGrouping { return this._dropzoneElm; } - get droppableInstance(): Sortable | undefined { - return this._droppableInstance; - } - - get sortableLeftInstance(): Sortable | undefined { - return this._sortableLeftInstance; - } - - get sortableRightInstance(): Sortable | undefined { - return this._sortableRightInstance; - } - get eventHandler(): SlickEventHandler { return this._eventHandler; } @@ -142,7 +134,7 @@ export class SlickDraggableGrouping { '[Slickgrid-Universal] Draggable Grouping requires the pre-header to be created and shown for the plugin to work correctly (use `createPreHeaderPanel` and `showPreHeaderPanel`).' ); } - this._dropzoneElm.classList.add('slick-dropzone'); + this._dropzoneElm.classList.add(DROPZONE_CLASS); // add PubSub instance to all SlickEvent this.onGroupChanged.setPubSubService(this.pubSubService); @@ -150,11 +142,11 @@ export class SlickDraggableGrouping { // add optional group "Toggle All" with its button & text when provided if (!this._addonOptions.hideToggleAllButton) { this._groupToggler = createDomElement('div', { - className: 'slick-group-toggle-all', + className: GROUP_TOGGLE_ALL_CLASS, title: this._addonOptions.toggleAllPlaceholderText ?? '', style: { display: 'none' }, }); - const groupTogglerIconElm = createDomElement('span', { className: 'sgi slick-group-toggle-all-icon' }, this._groupToggler); + const groupTogglerIconElm = createDomElement('span', { className: `sgi ${GROUP_TOGGLE_ALL_ICON_CLASS}` }, this._groupToggler); if (this.gridOptions.enableTranslate && this._addonOptions.toggleAllButtonTextKey) { this._addonOptions.toggleAllButtonText = this.extensionUtility.translateWhenEnabledAndServiceExist( @@ -187,7 +179,7 @@ export class SlickDraggableGrouping { ); } - this._dropzonePlaceholderElm = createDomElement('div', { className: 'slick-draggable-dropzone-placeholder' }, this._dropzoneElm); + this._dropzonePlaceholderElm = createDomElement('div', { className: DROPZONE_PLACEHOLDER_CLASS }, this._dropzoneElm); if (this.gridOptions.enableTranslate && this._addonOptions?.dropPlaceHolderTextKey) { this._addonOptions.dropPlaceHolderText = this.extensionUtility.translateWhenEnabledAndServiceExist( this._addonOptions.dropPlaceHolderTextKey, @@ -227,10 +219,9 @@ export class SlickDraggableGrouping { /** Dispose the plugin. */ dispose(): void { - this.destroySortableInstances(); - if (this._droppableInstance?.el) { - this._droppableInstance?.destroy(); - } + this.destroyColumnReorderDrag(); + this._dropboxDrag?.destroy(); + this._dropboxDrag = undefined; this.onGroupChanged.unsubscribe(); this._eventHandler.unsubscribeAll(); this.pubSubService.unsubscribeAll(this._subscriptions); @@ -255,13 +246,9 @@ export class SlickDraggableGrouping { } } - destroySortableInstances(): void { - if (this._sortableLeftInstance?.el) { - this._sortableLeftInstance?.destroy(); - } - if (this._sortableRightInstance?.el) { - this._sortableRightInstance?.destroy(); - } + destroyColumnReorderDrag(): void { + this._columnReorderDrag?.destroy(); + this._columnReorderDrag = undefined; } setAddonOptions(options: Partial): void { @@ -304,80 +291,61 @@ export class SlickDraggableGrouping { getColumnIndex: (columnId: string) => number, _uid: string, trigger: (slickEvent: SlickEvent, data?: any) => void - ): { - sortableLeftInstance: Sortable; - sortableRightInstance: Sortable; - } { - this.destroySortableInstances(); + ): void { + this.destroyColumnReorderDrag(); const dropzoneElm = grid.getTopHeaderPanel() || grid.getPreHeaderPanel(); - const draggablePlaceholderElm = dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder'); - const groupTogglerElm = dropzoneElm.querySelector('.slick-group-toggle-all'); - - const sortableOptions = { - animation: 50, - ghostClass: 'slick-sortable-placeholder', - draggable: '.slick-header-column', - dataIdAttr: 'data-id', - group: { - name: 'shared', - pull: 'clone', - put: false, - }, - // Fixes broken Firefox-Linux dragging - forceFallback: /firefox/i.test(navigator.userAgent) && /linux/i.test(navigator.userAgent), - revertClone: true, - // filter: function (_e, target) { - // // block column from being able to be dragged if it's already a grouped column - // // NOTE: need to disable for now since it also blocks the column reordering - // return columnsGroupBy.some(c => c.id === target.getAttribute('data-id')); - // }, - onStart: (e) => { - e.item.classList.add('slick-header-column-active'); + const draggablePlaceholderElm = dropzoneElm.querySelector(`.${DROPZONE_PLACEHOLDER_CLASS}`); + const groupTogglerElm = dropzoneElm.querySelector(`.${GROUP_TOGGLE_ALL_CLASS}`); + const uid = grid.getUID(); + + const restoreDropzoneState = () => { + dropzoneElm.classList.remove(DROPZONE_HOVER_CLASS); + draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); + const groupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); + groupingElms.forEach((el) => (el.style.display = 'flex')); + if (groupingElms.length) { + if (draggablePlaceholderElm) { + draggablePlaceholderElm.style.display = 'none'; + } + if (groupTogglerElm) { + groupTogglerElm.style.display = 'inline-flex'; + } + } + }; + + this._columnReorderDrag = setupColumnReorderDrag({ + headerLeft: this.gridContainer.querySelector(`.${uid} .slick-header-columns.slick-header-columns-left`)!, + headerRight: this.gridContainer.querySelector(`.${uid} .slick-header-columns.slick-header-columns-right`)!, + container: this.gridContainer, + viewportScrollContainerX: (grid as any).getViewportNode?.() ?? this.gridContainer, + hasFrozenColumns: () => ((grid.getOptions?.() as GridOption | undefined)?.frozenColumn ?? -1) >= 0, + draggableSelector: '.slick-header-column', + dropzoneSelector: `.${DROPZONE_CLASS}`, + dropzoneHoverClass: DROPZONE_HOVER_CLASS, + onDragStart: () => { if (draggablePlaceholderElm) { draggablePlaceholderElm.style.display = 'inline-block'; } - const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); - droppedGroupingElms.forEach((droppedGroupingElm) => (droppedGroupingElm.style.display = 'none')); + dropzoneElm.querySelectorAll('.slick-dropped-grouping').forEach((el) => (el.style.display = 'none')); if (groupTogglerElm) { groupTogglerElm.style.display = 'none'; } }, - onEnd: (e) => { - e.item.classList.remove('slick-header-column-active'); - dropzoneElm?.classList.remove('slick-dropzone-hover'); - draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); - - const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); - droppedGroupingElms.forEach((droppedGroupingElm) => (droppedGroupingElm.style.display = 'flex')); - - if (droppedGroupingElms.length) { - if (draggablePlaceholderElm) { - draggablePlaceholderElm.style.display = 'none'; - } - if (groupTogglerElm) { - groupTogglerElm.style.display = 'inline-flex'; - } + onDrop: (draggedEl, _event, draggedColumnId) => { + restoreDropzoneState(); + const headerColumnElm = + (draggedEl as HTMLDivElement | null) ?? this.gridContainer.querySelector(`[data-id="${draggedColumnId}"]`); + if (headerColumnElm) { + this.handleGroupByDrop(dropzoneElm, headerColumnElm); } + }, + onDragEnd: (reorderedIds) => { + restoreDropzoneState(); if (!grid.getEditorLock().commitCurrentEdit()) { return; } - const reorderedIds = this.sortableLeftInstance?.toArray() ?? []; - - // when frozen columns are used, headers has more than one entry and we need the ids from all of them. - // though there is only really a left and right header, this will work even if that should change. - if (headers.length > 1) { - const ids = this._sortableRightInstance?.toArray() ?? []; - - // Note: the loop below could be simplified with: - // reorderedIds.push.apply(reorderedIds,ids); - // However, the loop is more in keeping with way-backward compatibility - for (const id of ids) { - reorderedIds.push(id); - } - } - const finalReorderedColumns: Column[] = []; const reorderedColumns = grid.getColumns(); for (const reorderedId of reorderedIds) { @@ -385,19 +353,9 @@ export class SlickDraggableGrouping { } setColumns.call(grid, finalReorderedColumns); trigger.call(grid, grid.onColumnsReordered, { grid, impactedColumns: finalReorderedColumns }); - e.stopPropagation(); setupColumnResize.call(grid); }, - } as SortableOptions; - - this._sortableLeftInstance = Sortable.create( - this.gridContainer.querySelector(`.${grid.getUID()} .slick-header-columns.slick-header-columns-left`) as HTMLDivElement, - sortableOptions - ); - this._sortableRightInstance = Sortable.create( - this.gridContainer.querySelector(`.${grid.getUID()} .slick-header-columns.slick-header-columns-right`) as HTMLDivElement, - sortableOptions - ); + }); // user can optionally provide initial groupBy columns const initialGroupIds = this._addonOptions.initialGroupBy ?? this.gridOptions.presets?.grouping; @@ -405,11 +363,6 @@ export class SlickDraggableGrouping { this.setDroppedGroups(initialGroupIds); } this._isInitialized = true; - - return { - sortableLeftInstance: this._sortableLeftInstance, - sortableRightInstance: this._sortableRightInstance, - }; } // @@ -495,6 +448,7 @@ export class SlickDraggableGrouping { const entryElm = createDomElement('div', { id: `${this._gridUid}_${col.id}_entry`, className: 'slick-dropped-grouping', + draggable: true, // enable native drag-and-drop reordering within the dropzone dataset: { id: `${col.id}` }, }); createDomElement( @@ -553,7 +507,7 @@ export class SlickDraggableGrouping { } protected toggleGroupAll({ grouping }: Column, collapsed?: boolean): void { - const togglerIcon = this._groupToggler?.querySelector('.slick-group-toggle-all-icon'); + const togglerIcon = this._groupToggler?.querySelector(`.${GROUP_TOGGLE_ALL_ICON_CLASS}`); const isCollapsed = collapsed === true || grouping?.collapsed; togglerIcon?.classList.toggle('collapsed', isCollapsed); togglerIcon?.classList.toggle('expanded', !isCollapsed); @@ -585,59 +539,75 @@ export class SlickDraggableGrouping { } protected addDragOverDropzoneListeners(): void { - const draggablePlaceholderElm = this._dropzoneElm.querySelector('.slick-draggable-dropzone-placeholder'); + const draggablePlaceholderElm = this._dropzoneElm.querySelector(`.${DROPZONE_PLACEHOLDER_CLASS}`); if (draggablePlaceholderElm && this._dropzoneElm) { this._bindingEventService.bind(draggablePlaceholderElm, 'dragover', (e: Event) => e.preventDefault()); - this._bindingEventService.bind(draggablePlaceholderElm, 'dragenter', () => this._dropzoneElm.classList.add('slick-dropzone-hover')); - this._bindingEventService.bind(draggablePlaceholderElm, 'dragleave', () => - this._dropzoneElm.classList.remove('slick-dropzone-hover') - ); + this._bindingEventService.bind(draggablePlaceholderElm, 'dragenter', (e: Event) => { + const dragEvent = e as DragEvent; + if (dragEvent.dataTransfer?.types?.length) { + this._dropzoneElm.classList.add(DROPZONE_HOVER_CLASS); + } + }); + this._bindingEventService.bind(draggablePlaceholderElm, 'dragleave', () => this._dropzoneElm.classList.remove(DROPZONE_HOVER_CLASS)); } } protected setupColumnDropbox(): void { const dropzoneElm = this._dropzoneElm; + this._dropboxDrag?.destroy(); - this._droppableInstance = Sortable.create(dropzoneElm, { - group: 'shared', - ghostClass: 'slick-droppable-sortitem-hover', - draggable: '.slick-dropped-grouping', - dragoverBubble: true, - onAdd: (evt: SortableEvent) => { - const el = evt.item as HTMLDivElement; - if (el.getAttribute('id')?.replace(this._gridUid, '')) { - // use Sortable's clone method to properly clone the element but add the missing clone() type definition (it's missing in `@types/sortablejs`) - const clonedElm = (Sortable.utils as Sortable.Utils & { clone(el: T): T }).clone( - evt.item as HTMLDivElement - ); - this.handleGroupByDrop(dropzoneElm, clonedElm); - } - el.parentNode?.removeChild(el); - }, - onUpdate: () => { - const sortArray = this._droppableInstance?.toArray() ?? []; + this._dropboxDrag = setupDropzonePillDrag({ + dropzoneElm, + itemSelector: '.slick-dropped-grouping', + draggingCssClass: 'slick-dropped-grouping-dragging', + onPillDragEnd: () => { const newGroupingOrder: Column[] = []; - for (const sortGroupId of sortArray) { - for (const groupByColumn of this.columnsGroupBy) { - if (groupByColumn.id === sortGroupId) { - newGroupingOrder.push(groupByColumn); - break; - } + dropzoneElm.querySelectorAll('.slick-dropped-grouping').forEach((pillElm) => { + const id = pillElm.dataset.id; + const col = this.columnsGroupBy.find((c) => String(c.id) === id); + if (col) { + newGroupingOrder.push(col); } + }); + const isSameOrder = + newGroupingOrder.length === this.columnsGroupBy.length && newGroupingOrder.every((col, idx) => col === this.columnsGroupBy[idx]); + if (!isSameOrder) { + this.columnsGroupBy = newGroupingOrder; + this.updateGroupBy('sort-group'); + } + }, + onColumnDragEnter: (e) => { + if (e.dataTransfer?.types?.length) { + dropzoneElm.classList.add(DROPZONE_HOVER_CLASS); + } + }, + onColumnDragLeave: (e) => { + const nextTarget = + (e.relatedTarget as Node | null) ?? + (e.clientX != null && e.clientY != null ? (document.elementFromPoint(e.clientX, e.clientY) as Node | null) : null); + if (!nextTarget || !dropzoneElm.contains(nextTarget)) { + dropzoneElm.classList.remove(DROPZONE_HOVER_CLASS); } - this.columnsGroupBy = newGroupingOrder; - this.updateGroupBy('sort-group'); }, - } as SortableOptions); + onColumnDrop: (columnDataId) => { + dropzoneElm.classList.remove(DROPZONE_HOVER_CLASS); + const headerColumnElm = this.gridContainer.querySelector(`[data-id="${columnDataId}"]`); + if (headerColumnElm) { + this.handleGroupByDrop(dropzoneElm, headerColumnElm); + } + }, + }); - // Sortable doesn't have onOver, we need to implement it ourselves + // Keep the placeholder-level hover listeners (for visual feedback when cursor enters the placeholder) this.addDragOverDropzoneListeners(); if (this._groupToggler) { this._bindingEventService.bind(this._groupToggler, 'click', ((event: DOMMouseOrTouchEvent) => { // prettier-ignore - const target = event.target.classList.contains('slick-group-toggle-all-icon') ? event.target : event.currentTarget.querySelector('.slick-group-toggle-all-icon'); + const target = event.target.classList.contains(GROUP_TOGGLE_ALL_ICON_CLASS) + ? event.target + : event.currentTarget.querySelector(`.${GROUP_TOGGLE_ALL_ICON_CLASS}`); this.toggleGroupToggler(target, target?.classList.contains('expanded')); }) as EventListener); } diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index 87133657d4..2c5f443f63 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -135,6 +135,7 @@ export type * from './searchColumnFilter.interface.js'; export type * from './selectableOverrideCallback.interface.js'; export type * from './selectionModelOption.interface.js'; export type * from './selectOption.interface.js'; +export type * from './slickColumnReorderDrag.interfaces.js'; export type * from './slickPlugin.interface.js'; export type * from './slickRemoteModel.interface.js'; export type * from './slickResizer.interface.js'; diff --git a/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts b/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts new file mode 100644 index 0000000000..3cd17c1b02 --- /dev/null +++ b/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts @@ -0,0 +1,60 @@ +export interface ColumnReorderDragOption { + /** CSS class applied to dragged header columns while drag is active */ + dragActiveClass?: string; + /** CSS selector used to find draggable header items (default: `.slick-header-column`) */ + draggableSelector?: string; + /** Left header container (.slick-header-columns-left) */ + headerLeft: HTMLElement; + /** Right header container (.slick-header-columns-right) */ + headerRight: HTMLElement; + /** Grid container – used for the right-edge auto-scroll boundary */ + container: HTMLElement; + /** Scrollable viewport – used for the left-edge boundary and actual scrolling */ + viewportScrollContainerX: HTMLElement; + /** Returns true when the grid has frozen columns (determines which pane can auto-scroll) */ + hasFrozenColumns: () => boolean; + /** CSS class that marks a column as non-reorderable */ + unorderableColumnCssClass?: string; + /** Dropzone selector used to detect external drop targets (default: `.slick-dropzone`) */ + dropzoneSelector?: string; + /** CSS class toggled while hovering an external dropzone (default: `slick-dropzone-hover`) */ + dropzoneHoverClass?: string; + /** + * Generic filter to ignore drag starts from interactive descendants. + * - `string`: CSS selector used with `closest()` + * - `function`: return `true` to cancel drag start for this target + */ + dragStartFilter?: string | ((target: HTMLElement | null, event: DragEvent | MouseEvent | TouchEvent) => boolean); + /** + * Called right after dragstart, before any DOM changes. + * Use this to snapshot column state that your onDragEnd callback needs. + */ + onDragStart?: (draggedEl: HTMLElement) => void; + /** + * Called when drag ends with the new visible-column ID order read from the DOM. + * Responsible for applying the reorder (setColumns, triggerEvent, etc.). + */ + onDragEnd: (reorderedIds: string[]) => void; + /** + * Called when the drag is dropped onto an external dropzone such as draggable grouping. + */ + onDrop?: (draggedEl: HTMLElement, event: DragEvent | MouseEvent | TouchEvent, draggedColumnId?: string) => void; +} + +/** Dropzone pill drag (used by DraggableGrouping to reorder group pills) */ +export interface DropzonePillDragOption { + /** The dropzone container element */ + dropzoneElm: HTMLElement; + /** CSS selector for draggable pill elements inside the dropzone (default: `.slick-dropped-grouping`) */ + itemSelector?: string; + /** CSS class added to a pill while it is being dragged via the mouse fallback */ + draggingCssClass?: string; + /** Called when a pill reorder drag-and-drop is complete */ + onPillDragEnd?: (pill: HTMLElement) => void; + /** Called when an external drag (e.g. column header) enters the dropzone */ + onColumnDragEnter?: (e: DragEvent) => void; + /** Called when an external drag leaves the dropzone */ + onColumnDragLeave?: (e: DragEvent) => void; + /** Called when a column header is natively dropped onto the dropzone */ + onColumnDrop?: (columnDataId: string, e: DragEvent) => void; +} diff --git a/packages/common/src/styles/slick-grid.scss b/packages/common/src/styles/slick-grid.scss index 8aaf14700d..7fbea1371f 100644 --- a/packages/common/src/styles/slick-grid.scss +++ b/packages/common/src/styles/slick-grid.scss @@ -42,6 +42,17 @@ border-color: var(--slick-btn-default-border-hover-color, v.$slick-btn-default-border-hover-color); } } + +// slickgrid internal icons (used for grouping, tree data, pagination, etc..) +.sgi { + display: inline-flex; + align-items: center; + background-color: currentColor; + font-size: var(--slick-icon-font-size, v.$slick-icon-font-size); + width: 1em; + height: 1em; +} + .slick-dark-mode { .btn-default { color: var(--slick-btn-default-text-color, v.$slick-btn-default-text-color); @@ -52,14 +63,13 @@ } } -/* slickgrid internal icons (used for grouping, tree data, pagination, etc..) */ -.sgi { - display: inline-flex; - align-items: center; - background-color: currentColor; - font-size: var(--slick-icon-font-size, v.$slick-icon-font-size); - width: 1em; - height: 1em; +// Firefox fallback when dragging a column header (since Firefox doesn't support the drag image) +.slick-header-column-drag-ghost { + position: fixed; + margin: 0; + pointer-events: none; + z-index: 9999; + transform: translate(-8px, -8px); } .slickgrid-container { diff --git a/packages/common/types/sortablejs.d.ts b/packages/common/types/sortablejs.d.ts deleted file mode 100644 index 28d42efe12..0000000000 --- a/packages/common/types/sortablejs.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'sortablejs/modular/sortable.core.esm.js' { - import Sortable from 'sortablejs'; - export default Sortable; -} diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts index 2914c30597..bf266bd08d 100644 --- a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts +++ b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts @@ -49,8 +49,6 @@ import { TranslateServiceStub } from '../../../../../test/translateServiceStub.j import { UniversalContainerService } from '../../services/universalContainer.service.js'; import { SlickVanillaGridBundle } from '../slick-vanilla-grid-bundle.js'; -vi.mock('sortablejs'); - vi.useFakeTimers(); const addVanillaEventPropagation = function (event: any) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e18cc5990..b661d30cdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,9 +36,6 @@ catalogs: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3 - '@types/sortablejs': - specifier: ^1.15.9 - version: 1.15.9 '@vitejs/plugin-react': specifier: ^6.0.3 version: 6.0.3 @@ -117,9 +114,6 @@ catalogs: sass: specifier: ^1.101.0 version: 1.101.0 - sortablejs: - specifier: ^1.15.7 - version: 1.15.7 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -236,9 +230,6 @@ importers: rxjs: specifier: 'catalog:' version: 7.8.2 - sortablejs: - specifier: 'catalog:' - version: 1.15.7 typescript: specifier: 'catalog:' version: 6.0.3 @@ -1339,9 +1330,6 @@ importers: '@slickgrid-universal/utils': specifier: workspace:* version: link:../utils - '@types/sortablejs': - specifier: 'catalog:' - version: 1.15.9 '@types/trusted-types': specifier: ^2.0.7 version: 2.0.7 @@ -1354,9 +1342,6 @@ importers: multiple-select-vanilla: specifier: 'catalog:' version: 5.2.1 - sortablejs: - specifier: 'catalog:' - version: 1.15.7 vanilla-calendar-pro: specifier: ^3.1.0 version: 3.1.0 @@ -4524,9 +4509,6 @@ packages: '@types/sizzle@2.3.10': resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} - '@types/sortablejs@1.15.9': - resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} - '@types/tmp@0.2.6': resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} @@ -7706,9 +7688,6 @@ packages: resolution: {integrity: sha512-ueSlHJMwpIw42CJ4B11Uxzh/S0p0AlOyiNktlv2KOu5e1JpUE6DlC4AAUjXqesHdBRv/g0wC9Q4vwq0NP2pA9w==} engines: {node: '>=20'} - sortablejs@1.15.7: - resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -11848,8 +11827,6 @@ snapshots: '@types/sizzle@2.3.10': {} - '@types/sortablejs@1.15.9': {} - '@types/tmp@0.2.6': {} '@types/trusted-types@2.0.7': {} @@ -15396,8 +15373,6 @@ snapshots: dependencies: is-plain-obj: 4.1.0 - sortablejs@1.15.7: {} - source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 99d52489cc..c7f1adf06f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,7 +16,6 @@ catalog: '@types/node': ^24.13.2 '@types/react': ^19.2.17 '@types/react-dom': ^19.2.3 - '@types/sortablejs': ^1.15.9 '@vitejs/plugin-react': ^6.0.3 '@vitejs/plugin-vue': ^6.0.7 '@vitest/coverage-v8': ^4.1.9 @@ -43,7 +42,6 @@ catalog: remove-glob: ^1.2.1 rxjs: ^7.8.2 sass: ^1.101.0 - sortablejs: ^1.15.7 tslib: ^2.8.1 typescript: ^6.0.3 vite: ^8.1.3 diff --git a/test/cypress/e2e/example04.cy.ts b/test/cypress/e2e/example04.cy.ts index e3f939fe7c..08dcb304c7 100644 --- a/test/cypress/e2e/example04.cy.ts +++ b/test/cypress/e2e/example04.cy.ts @@ -896,25 +896,43 @@ describe('Example 04 - Frozen Grid', () => { }); cy.get('.slick-viewport-top.slick-viewport-right').its('0.scrollLeft').should('equal', 0); - // Step 1: call SortableJS onStart for the "Start" column (1st right-section column). - // This sets canDragScroll=true (it's in _headerR) and binds the document 'drag' auto-scroll listener. - cy.get('.slick-header-columns-right').then(($rightHeader) => { - let sortInstance: any; - Object.keys($rightHeader[0]).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = ($rightHeader[0] as any)[prop]; - } - }); - expect(sortInstance).to.exist; - const startColumnEl = $rightHeader[0].querySelectorAll('.slick-header-column')[0] as HTMLElement; - sortInstance.options.onStart({ item: startColumnEl }); - }); - - // Step 2: fire a document drag event well past the right edge (viewport-relative) - // to avoid CI flakiness caused by environment-dependent viewport widths. + const dataStore = new Map(); + const dragDataTransfer = { + dropEffect: 'move', + effectAllowed: 'all', + getData: (format: string) => dataStore.get(format) ?? '', + setData: (format: string, data: string) => { + dataStore.set(format, data); + }, + setDragImage: () => {}, + }; + + const createDragLikeEvent = (eventName: string, x: number, y: number): Event => { + const evt = new Event(eventName, { bubbles: true, cancelable: true }); + Object.defineProperty(evt, 'dataTransfer', { value: dragDataTransfer }); + Object.defineProperty(evt, 'clientX', { value: x }); + Object.defineProperty(evt, 'clientY', { value: y }); + Object.defineProperty(evt, 'pageX', { value: x }); + Object.defineProperty(evt, 'pageY', { value: y }); + Object.defineProperty(evt, 'screenX', { value: x }); + Object.defineProperty(evt, 'screenY', { value: y }); + return evt; + }; + + // Step 1-2: start native drag on "Start" then fire document drag far-right to trigger auto-scroll. cy.window().then((win) => { + const rightHeader = win.document.querySelector('.slick-header-columns-right') as HTMLElement; + const cols = Array.from(rightHeader?.querySelectorAll('.slick-header-column') ?? []) as HTMLElement[]; + const startColumnEl = cols.find((el) => (el.textContent ?? '').includes('Start')) as HTMLElement; + expect(startColumnEl).to.exist; + + const startRect = startColumnEl.getBoundingClientRect(); + const startX = startRect.left + startRect.width / 2; + const startY = startRect.top + startRect.height / 2; + startColumnEl.dispatchEvent(createDragLikeEvent('dragstart', startX, startY)); + const dragX = win.innerWidth + 1200; - cy.document().trigger('drag', { pageX: dragX, clientX: dragX, clientY: 50 }); + win.document.dispatchEvent(createDragLikeEvent('drag', dragX, startY)); }); // Step 3: advance mocked time so the 100ms scroll interval ticks several times. @@ -923,25 +941,24 @@ describe('Example 04 - Frozen Grid', () => { // Auto-scroll should have moved the right viewport to the right cy.get('.slick-viewport-top.slick-viewport-right').its('0.scrollLeft').should('be.greaterThan', 0); - // Step 4: simulate the drag result — "Start" was moved to the right, past "Finish". - // SortableJS reads the DOM order via toArray() inside onEnd, so physically reorder the children first. - cy.get('.slick-header-columns-right').then(($rightHeader) => { - let sortInstance: any; - Object.keys($rightHeader[0]).forEach((prop) => { - if (prop.startsWith('Sortable')) { - sortInstance = ($rightHeader[0] as any)[prop]; - } - }); - expect(sortInstance).to.exist; - const cols = $rightHeader[0].querySelectorAll('.slick-header-column'); - const startColumnEl = cols[0] as HTMLElement; // "Start" - const finishColumnEl = cols[1] as HTMLElement; // "Finish" - - // Move "Finish" before "Start" → mirrors dragging Start past Finish - $rightHeader[0].insertBefore(finishColumnEl, startColumnEl); - - // onEnd reads the new DOM order via toArray() and calls setColumns() if the order changed - sortInstance.options.onEnd({ item: startColumnEl, stopPropagation: () => {} }); + // Step 4: drag over "Finish" on its right half then end drag to reorder as Finish -> Start. + cy.window().then((win) => { + const rightHeader = win.document.querySelector('.slick-header-columns-right') as HTMLElement; + const cols = Array.from(rightHeader?.querySelectorAll('.slick-header-column') ?? []) as HTMLElement[]; + const startColumnEl = cols.find((el) => el.textContent?.includes('Start')) as HTMLElement; + const finishColumnEl = cols.find((el) => el.textContent?.includes('Finish')) as HTMLElement; + + expect(startColumnEl).to.exist; + expect(finishColumnEl).to.exist; + + const finishRect = finishColumnEl.getBoundingClientRect(); + const targetX = finishRect.left + finishRect.width / 2; + const targetY = finishRect.top + finishRect.height / 2; + + finishColumnEl.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY)); + finishColumnEl.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY)); + finishColumnEl.dispatchEvent(createDragLikeEvent('drop', targetX, targetY)); + startColumnEl.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY)); }); // Right section should now be: Finish, Start, Completed, Cost | Duration, City of Origin, Action diff --git a/test/cypress/support/commands.ts b/test/cypress/support/commands.ts index 714733c727..815e464c8a 100644 --- a/test/cypress/support/commands.ts +++ b/test/cypress/support/commands.ts @@ -25,7 +25,7 @@ // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) import '@4tw/cypress-drag-drop'; import 'cypress-real-events'; -// eslint-disable-next-line n/file-extension-in-import +import './drag'; import { convertPosition } from './common'; declare global { @@ -33,24 +33,30 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; - convertPosition(viewport: string): Chainable | { x: string; y: string }>; - getCell( - row: number, - col: number, - viewport?: string, - options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; - getNthCell( - row: number, - nthCol: number, - viewport?: string, - options?: { parentSelector?: string; rowHeight?: number } - ): Chainable>; + convertPosition(viewport: string): Chainable; + getCell(row: number, col: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number }): Chainable; + getNthCell(row: number, nthCol: number, viewport?: string, options?: { parentSelector?: string; rowHeight?: number }): Chainable; getTransformValue(cssTransformMatrix: string, absoluteValue: boolean, transformType?: 'rotate' | 'scale'): Chainable; + saveLocalStorage: () => void; + restoreLocalStorage: () => void; } } } +const LOCAL_STORAGE_MEMORY: any = {}; + +Cypress.Commands.add('saveLocalStorage', () => { + Object.keys(localStorage).forEach((key) => { + LOCAL_STORAGE_MEMORY[key] = localStorage[key]; + }); +}); + +Cypress.Commands.add('restoreLocalStorage', () => { + Object.keys(LOCAL_STORAGE_MEMORY).forEach((key) => { + localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]); + }); +}); + // convert position like 'topLeft' to the object { x: 'left|right', y: 'top|bottom' } Cypress.Commands.add('convertPosition', (viewport = 'topLeft') => cy.wrap(convertPosition(viewport))); diff --git a/test/cypress/support/drag.ts b/test/cypress/support/drag.ts index ba31fd9ed8..90be58d6f9 100644 --- a/test/cypress/support/drag.ts +++ b/test/cypress/support/drag.ts @@ -6,6 +6,7 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; + drag(target: string | HTMLElement | JQuery, options?: any): Chainable; dragOutside( viewport?: string, ms?: number, @@ -18,6 +19,53 @@ declare global { } } } + +Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, _options: any = {}) => { + return cy.wrap(subject as JQuery).then(($source) => { + const rawSourceElm = $source?.[0] as HTMLElement | undefined; + const rawTargetElm = + typeof target === 'string' + ? (Cypress.$(target).first()?.[0] as HTMLElement | undefined) + : ((target as JQuery)?.[0] as HTMLElement | undefined); + const sourceElm = + rawSourceElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawSourceElm; + const targetElm = + rawTargetElm?.closest('.slick-header-column, .slick-dropped-grouping, [draggable="true"]') ?? rawTargetElm; + + if (!sourceElm || !targetElm) { + return $source; + } + + const sourceRect = sourceElm.getBoundingClientRect(); + const targetRect = targetElm.getBoundingClientRect(); + const sourceX = Number.isFinite(sourceRect.left + sourceRect.width / 2) ? sourceRect.left + sourceRect.width / 2 : 0; + const sourceY = Number.isFinite(sourceRect.top + sourceRect.height / 2) ? sourceRect.top + sourceRect.height / 2 : 0; + const targetX = Number.isFinite(targetRect.left + targetRect.width / 2) ? targetRect.left + targetRect.width / 2 : 0; + const targetY = Number.isFinite(targetRect.top + targetRect.height / 2) ? targetRect.top + targetRect.height / 2 : 0; + + const dataTransfer = new DataTransfer(); + const createDragLikeEvent = (eventName: string, x: number, y: number): Event => { + const evt = new Event(eventName, { bubbles: true, cancelable: true }); + Object.defineProperty(evt, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(evt, 'clientX', { value: x }); + Object.defineProperty(evt, 'clientY', { value: y }); + Object.defineProperty(evt, 'pageX', { value: x }); + Object.defineProperty(evt, 'pageY', { value: y }); + Object.defineProperty(evt, 'screenX', { value: x }); + Object.defineProperty(evt, 'screenY', { value: y }); + return evt; + }; + + sourceElm.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY)); + targetElm.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY)); + targetElm.dispatchEvent(createDragLikeEvent('drop', targetX, targetY)); + sourceElm.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY)); + + return $source; + }); +}); + // @ts-ignore Cypress.Commands.add('dragStart', { prevSubject: true }, (subject: HTMLElement, { cellWidth = 90, cellHeight = 35 } = {}) => { return cy