From bcefcbf2b3690ffb787735a99d2c2ede4b4d2af5 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 30 Jun 2026 18:37:58 -0400 Subject: [PATCH 01/19] refactor!: drop SortableJS dependency --- .../aurelia/test/cypress/support/commands.ts | 9 +- demos/aurelia/test/cypress/support/drag.ts | 48 ++ demos/react/test/cypress/support/commands.ts | 9 +- demos/react/test/cypress/support/drag.ts | 48 ++ demos/vue/test/cypress/support/commands.ts | 9 +- demos/vue/test/cypress/support/drag.ts | 48 ++ .../test/cypress/support/commands.ts | 35 +- .../test/cypress/support/drag.ts | 48 ++ .../angular-slickgrid/types/sortablejs.d.ts | 4 - frameworks/slickgrid-vue/vite.config.mts | 1 - package.json | 4 +- packages/common/package.json | 2 - .../__tests__/slickColumnReorderDrag.spec.ts | 269 +++++++++++ .../src/core/__tests__/slickGrid.spec.ts | 309 +++++-------- packages/common/src/core/index.ts | 1 + .../common/src/core/slickColumnReorderDrag.ts | 167 +++++++ packages/common/src/core/slickGrid.ts | 133 ++---- .../__tests__/slickDraggableGrouping.spec.ts | 419 ++++++++---------- .../src/extensions/slickDraggableGrouping.ts | 234 +++++----- packages/common/types/sortablejs.d.ts | 4 - .../__tests__/slick-vanilla-grid.spec.ts | 2 - pnpm-lock.yaml | 25 -- pnpm-workspace.yaml | 2 - test/cypress/e2e/example04.cy.ts | 85 ++-- test/cypress/support/commands.ts | 34 +- test/cypress/support/drag.ts | 48 ++ 26 files changed, 1224 insertions(+), 773 deletions(-) delete mode 100644 frameworks/angular-slickgrid/types/sortablejs.d.ts create mode 100644 packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts create mode 100644 packages/common/src/core/slickColumnReorderDrag.ts delete mode 100644 packages/common/types/sortablejs.d.ts 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 d54010af97..e6e6abdaa1 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.8.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 e5cc10428e..ccd12c61df 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..15afcb3d9d --- /dev/null +++ b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts @@ -0,0 +1,269 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setupColumnReorderDrag } 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; + }; + + 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 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 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); + }); +}); diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 978a206320..5076d9c574 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..6d96ee2394 --- /dev/null +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -0,0 +1,167 @@ +import { getOffset } from '@slickgrid-universal/utils'; + +export interface ColumnReorderDragOption { + /** 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; + /** + * 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; +} + +/** + * 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; + + let columnScrollTimer: ReturnType | undefined; + let draggedEl: HTMLElement | null = null; + + const scrollColumnsRight = () => (viewportScrollContainerX.scrollLeft += 10); + const scrollColumnsLeft = () => (viewportScrollContainerX.scrollLeft -= 10); + const stopAutoScroll = () => { + clearInterval(columnScrollTimer); + columnScrollTimer = undefined; + }; + + const isDraggable = (el: HTMLElement): boolean => + el.classList.contains('slick-header-column') && (!unorderableColumnCssClass || !el.classList.contains(unorderableColumnCssClass)); + + 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.classList.contains('slick-header-column')) { + el.draggable = isDraggable(el); + } + }); + }; + refreshDraggable(headerLeft); + refreshDraggable(headerRight); + + const autoScrollHandler = (e: DragEvent) => { + const { clientX, clientY, pageX } = e; + if (clientX && clientY) { + const containerOffset = getOffset(container); + const viewportLeft = getOffset(viewportScrollContainerX).left; + const containerRight = containerOffset.left + 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 onDragStart = (e: DragEvent) => { + const target = (e.target as HTMLElement).closest('.slick-header-column'); + if (!target || !isDraggable(target)) { + e.preventDefault(); + return; + } + draggedEl = target; + target.classList.add('slick-header-column-active'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + // Store column id so the dropzone can identify which column was dragged + if (typeof e.dataTransfer.setData === 'function') { + e.dataTransfer.setData('text/plain', target.dataset.id ?? ''); + } + // Explicit drag image avoids Firefox+Linux X11 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 e.dataTransfer.setDragImage === 'function') { + const rect = target.getBoundingClientRect(); + e.dataTransfer.setDragImage(target, e.clientX - rect.left, e.clientY - rect.top); + } + } + + options.onDragStart?.(target); + + // 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); + } + }; + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + const target = (e.target as HTMLElement).closest('.slick-header-column'); + if (!draggedEl || !target || target === draggedEl || !isDraggable(target)) { + return; + } + const rect = target.getBoundingClientRect(); + const insertBefore = e.clientX < rect.left + rect.width / 2; + target.parentElement!.insertBefore(draggedEl, insertBefore ? target : target.nextSibling); + }; + + const onDragEnd = (e: DragEvent) => { + if (draggedEl) { + draggedEl.classList.remove('slick-header-column-active'); + draggedEl = null; + } + stopAutoScroll(); + document.removeEventListener('drag', autoScrollHandler as EventListener); + + const reorderedIds = getColumnIds(headerLeft).concat(getColumnIds(headerRight)); + e.stopPropagation(); + options.onDragEnd(reorderedIds); + }; + + for (const parent of [headerLeft, headerRight]) { + parent.addEventListener('dragstart', onDragStart as EventListener); + parent.addEventListener('dragover', onDragOver as EventListener); + parent.addEventListener('dragend', onDragEnd as EventListener); + } + + return { + destroy() { + for (const parent of [headerLeft, headerRight]) { + parent.removeEventListener('dragstart', onDragStart as EventListener); + parent.removeEventListener('dragover', onDragOver as EventListener); + parent.removeEventListener('dragend', onDragEnd as EventListener); + } + document.removeEventListener('drag', autoScrollHandler as EventListener); + stopAutoScroll(); + draggedEl = null; + [headerLeft, headerRight].forEach((parent) => + Array.from(parent.querySelectorAll('.slick-header-column')).forEach((el) => (el.draggable = false)) + ); + }, + }; +} diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 394c949368..f1024fb5af 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,52 @@ 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(), + 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 +2193,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[] { @@ -3073,12 +3011,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..4f8dd111c2 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,59 +347,36 @@ 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(); @@ -447,24 +386,12 @@ describe('Draggable Grouping Plugin', () => { 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: {} }); @@ -479,6 +406,69 @@ describe('Draggable Grouping Plugin', () => { 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']); + }); + describe('setupColumnDropbox method', () => { describe('setupColumnDropbox update & toggler click event', () => { let groupChangedSpy: any; @@ -509,22 +499,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,20 +543,27 @@ 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'); @@ -673,30 +663,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 +682,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 +693,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 +710,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 +749,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 +792,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 +806,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..216b7ac6ee 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 } from '../core/slickColumnReorderDrag.js'; import { SortDirectionNumber } from '../enums/index.js'; import type { Column, @@ -47,7 +46,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 +57,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 +90,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; } @@ -227,10 +213,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 +240,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,52 +285,33 @@ 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 uid = grid.getUID(); + + 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, + 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'); + onDragEnd: (reorderedIds) => { 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')); - + droppedGroupingElms.forEach((el) => (el.style.display = 'flex')); if (droppedGroupingElms.length) { if (draggablePlaceholderElm) { draggablePlaceholderElm.style.display = 'none'; @@ -363,21 +325,6 @@ export class SlickDraggableGrouping { 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 +332,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 +342,6 @@ export class SlickDraggableGrouping { this.setDroppedGroups(initialGroupIds); } this._isInitialized = true; - - return { - sortableLeftInstance: this._sortableLeftInstance, - sortableRightInstance: this._sortableRightInstance, - }; } // @@ -495,6 +427,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( @@ -598,40 +531,101 @@ export class SlickDraggableGrouping { protected setupColumnDropbox(): void { const dropzoneElm = this._dropzoneElm; - - 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() ?? []; - const newGroupingOrder: Column[] = []; - for (const sortGroupId of sortArray) { - for (const groupByColumn of this.columnsGroupBy) { - if (groupByColumn.id === sortGroupId) { - newGroupingOrder.push(groupByColumn); - break; - } + this._dropboxDrag?.destroy(); + + let draggedPill: HTMLElement | null = null; + + // --- Group pill reordering within the dropzone --- + const onDragStart = (e: DragEvent) => { + const pill = (e.target as HTMLElement).closest('.slick-dropped-grouping'); + if (pill) { + draggedPill = pill; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + // Firefox requires setData() to be called in dragstart, otherwise the drag won't initiate + if (typeof e.dataTransfer.setData === 'function') { + e.dataTransfer.setData('text/plain', pill.dataset.id ?? ''); + } + // Explicit drag image avoids Firefox+Linux X11 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(); // allow drop in all cases + const target = (e.target as HTMLElement).closest('.slick-dropped-grouping'); + 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) { + draggedPill = null; + // Read new pill order from DOM and update columnsGroupBy + const newGroupingOrder: Column[] = []; + 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); + }); this.columnsGroupBy = newGroupingOrder; this.updateGroupBy('sort-group'); + } + }; + + // --- Accept column header drops (creates a group pill) --- + const onDragEnter = (e: DragEvent) => { + if (!draggedPill) dropzoneElm.classList.add('slick-dropzone-hover'); + e.preventDefault(); + }; + + const onDragLeave = (e: DragEvent) => { + // Only remove hover when cursor actually leaves the dropzone, not just entering a child + if (!draggedPill && !dropzoneElm.contains(e.relatedTarget as Node)) { + dropzoneElm.classList.remove('slick-dropzone-hover'); + } + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + dropzoneElm.classList.remove('slick-dropzone-hover'); + if (!draggedPill) { + // Pill reorder is finalized in dragend, so only handle header drops when no pill drag is active. + const columnDataId = e.dataTransfer?.getData('text/plain'); + if (columnDataId) { + const headerColumnElm = this.gridContainer.querySelector(`[data-id="${columnDataId}"]`); + if (headerColumnElm) { + this.handleGroupByDrop(dropzoneElm, headerColumnElm); + } + } + } + }; + + 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); + + this._dropboxDrag = { + 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); }, - } as SortableOptions); + }; - // 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) { 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 7714d9bb99..aa23390894 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.2 version: 6.0.2 @@ -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 @@ -4523,9 +4508,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==} @@ -7716,9 +7698,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'} @@ -11876,8 +11855,6 @@ snapshots: '@types/sizzle@2.3.10': {} - '@types/sortablejs@1.15.9': {} - '@types/tmp@0.2.6': {} '@types/trusted-types@2.0.7': {} @@ -15456,8 +15433,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 d799fc1e30..0040713035 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.2 '@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.0.16 diff --git a/test/cypress/e2e/example04.cy.ts b/test/cypress/e2e/example04.cy.ts index b394bba363..08dcb304c7 100644 --- a/test/cypress/e2e/example04.cy.ts +++ b/test/cypress/e2e/example04.cy.ts @@ -896,23 +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. @@ -921,23 +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 From c0ed485c429318839f90bf991b683978fa6db470 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 1 Jul 2026 01:55:20 -0400 Subject: [PATCH 02/19] chore: fix a few issues with Firefox in Linux --- .../__tests__/slickColumnReorderDrag.spec.ts | 89 ++++++ .../common/src/core/slickColumnReorderDrag.ts | 299 +++++++++++++++++- .../__tests__/slickDraggableGrouping.spec.ts | 90 ++++++ .../src/extensions/slickDraggableGrouping.ts | 88 +++++- 4 files changed, 554 insertions(+), 12 deletions(-) diff --git a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts index 15afcb3d9d..47450a7093 100644 --- a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts +++ b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts @@ -224,6 +224,53 @@ describe('slickColumnReorderDrag', () => { 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'); @@ -266,4 +313,46 @@ describe('slickColumnReorderDrag', () => { // 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(); + }); }); diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 6d96ee2394..c081cad5b7 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -23,6 +23,10 @@ export interface ColumnReorderDragOption { * 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, draggedColumnId?: string) => void; } /** @@ -42,6 +46,13 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest let columnScrollTimer: ReturnType | undefined; let draggedEl: HTMLElement | null = null; + let fallbackActive = false; + 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; const scrollColumnsRight = () => (viewportScrollContainerX.scrollLeft += 10); const scrollColumnsLeft = () => (viewportScrollContainerX.scrollLeft -= 10); @@ -53,6 +64,11 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const isDraggable = (el: HTMLElement): boolean => el.classList.contains('slick-header-column') && (!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. + // Include Wayland because Firefox on Linux Wayland may omit the X11 token. + const isFfLinux = + typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) && /(?:linux|wayland)/i.test(navigator.userAgent); + const getColumnIds = (parent: HTMLElement): string[] => Array.from(parent.children) .filter((el) => isDraggable(el as HTMLElement)) @@ -63,7 +79,8 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const refreshDraggable = (parent: HTMLElement) => { Array.from(parent.children as HTMLCollectionOf).forEach((el) => { if (el.classList.contains('slick-header-column')) { - el.draggable = isDraggable(el); + // Disable native HTML5 drag on Firefox/Linux and use mouse fallback + el.draggable = isDraggable(el) && !isFfLinux; } }); }; @@ -86,6 +103,88 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } }; + const clearDropzoneTarget = () => { + dropzoneTargetActive = false; + }; + + const clearFallbackGhost = () => { + if (dragGhost?.parentElement) { + dragGhost.parentElement.removeChild(dragGhost); + } + dragGhost = null; + }; + + const updateFallbackGhost = (clientX: number, clientY: number) => { + if (!dragGhost) { + return; + } + dragGhost.style.left = `${clientX}px`; + dragGhost.style.top = `${clientY}px`; + }; + + const createFallbackGhost = (source: HTMLElement, clientX: number, clientY: number) => { + clearFallbackGhost(); + dragGhost = source.cloneNode(true) as HTMLElement; + dragGhost.classList.add('slick-header-column-drag-ghost'); + dragGhost.style.position = 'fixed'; + dragGhost.style.left = `${clientX}px`; + dragGhost.style.top = `${clientY}px`; + dragGhost.style.margin = '0'; + dragGhost.style.pointerEvents = 'none'; + dragGhost.style.zIndex = '9999'; + dragGhost.style.transform = 'translate(-8px, -8px)'; + document.body.appendChild(dragGhost); + }; + + const onDropzoneDragEnter = (e: Event) => { + const target = e.target as HTMLElement | null; + if (draggedEl && target?.closest?.('.slick-dropzone')) { + dropzoneTargetActive = true; + // Ensure the dragged header remains in the header DOM (non-destructive) + try { + if (originalParent && draggedEl.parentElement !== originalParent) { + originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); + } + } catch (err) { + // ignore + } + } + }; + + const onDropzoneDragLeave = (e: Event) => { + const target = e.target as HTMLElement | null; + if (target?.closest?.('.slick-dropzone')) { + dropzoneTargetActive = false; + } + }; + + const onDropzoneMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement | null; + if (draggedEl && target?.closest?.('.slick-dropzone')) { + dropzoneTargetActive = true; + try { + if (originalParent && draggedEl.parentElement !== originalParent) { + originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); + } + } catch (err) { + // ignore + } + } + }; + + const onDropzoneMouseOut = (e: MouseEvent) => { + const target = e.target as HTMLElement | null; + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (target?.closest?.('.slick-dropzone') && !relatedTarget?.closest?.('.slick-dropzone')) { + dropzoneTargetActive = false; + } + }; + + document.addEventListener('dragenter', onDropzoneDragEnter as EventListener); + document.addEventListener('dragleave', onDropzoneDragLeave as EventListener); + document.addEventListener('mouseover', onDropzoneMouseOver as EventListener); + document.addEventListener('mouseout', onDropzoneMouseOut as EventListener); + const onDragStart = (e: DragEvent) => { const target = (e.target as HTMLElement).closest('.slick-header-column'); if (!target || !isDraggable(target)) { @@ -93,6 +192,11 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest return; } draggedEl = target; + draggedColumnId = target.dataset.id ?? ''; + clearDropzoneTarget(); + // remember original position so we can restore it if dropped outside headers (eg. dropzone) + originalParent = target.parentElement; + originalNextSibling = target.nextSibling; target.classList.add('slick-header-column-active'); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; @@ -100,7 +204,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest if (typeof e.dataTransfer.setData === 'function') { e.dataTransfer.setData('text/plain', target.dataset.id ?? ''); } - // Explicit drag image avoids Firefox+Linux X11 ghost rendering issues. + // 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). @@ -112,6 +216,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest options.onDragStart?.(target); + // reset pointer tracking for this drag session + _lastClientX = e.clientX ?? null; + // Only non-frozen columns should trigger browser-edge auto-scroll const canAutoScroll = !options.hasFrozenColumns() || headerRight.contains(target); if (canAutoScroll) { @@ -121,32 +228,190 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const onDragOver = (e: DragEvent) => { e.preventDefault(); + const overDropzone = (e.target as HTMLElement | null)?.closest?.('.slick-dropzone'); + if (overDropzone) { + dropzoneTargetActive = true; + // Keep the dragged header visible in the original header DOM while over the dropzone + try { + if (originalParent && draggedEl && draggedEl.parentElement !== originalParent) { + originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); + } + } catch (err) { + // ignore + } + return; + } + const target = (e.target as HTMLElement).closest('.slick-header-column'); if (!draggedEl || !target || target === draggedEl || !isDraggable(target)) { return; } + + // Only reorder against actual header containers, never against pre-header content. + const targetParent = target.parentElement; + if (!targetParent || (!headerLeft.contains(targetParent) && !headerRight.contains(targetParent))) { + return; + } + const rect = target.getBoundingClientRect(); - const insertBefore = e.clientX < rect.left + rect.width / 2; + const movingRight = _lastClientX == null ? e.clientX >= rect.left + rect.width / 2 : e.clientX > _lastClientX; + _lastClientX = e.clientX; + const insertBefore = !movingRight; target.parentElement!.insertBefore(draggedEl, insertBefore ? target : target.nextSibling); }; const onDragEnd = (e: DragEvent) => { - if (draggedEl) { - draggedEl.classList.remove('slick-header-column-active'); - draggedEl = null; + const draggedHeader = draggedEl; + if (draggedHeader) { + draggedHeader.classList.remove('slick-header-column-active'); } 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; + try { + 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('.slick-dropzone')) { + droppedOnDropzone = true; + } + } + } catch (err) { + 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(); - options.onDragEnd(reorderedIds); + if (droppedOnDropzone && draggedHeader) { + options.onDrop?.(draggedHeader, e, draggedColumnId); + } else { + options.onDragEnd(reorderedIds); + } + // clear stored original position + draggedEl = null; + originalParent = null; + originalNextSibling = null; + draggedColumnId = ''; + clearDropzoneTarget(); + fallbackActive = false; + _lastClientX = null; + }; + + // Firefox on Linux has broken/unstable native HTML5 drag behavior in many setups. + // Provide a mouse-based fallback that mimics SortableJS's `forceFallback`. + const onFallbackMouseDown = (e: MouseEvent) => { + const target = (e.target as HTMLElement).closest('.slick-header-column'); + if (!target || !isDraggable(target)) return; + e.preventDefault(); + draggedEl = target; + draggedColumnId = target.dataset.id ?? ''; + originalParent = target.parentElement; + originalNextSibling = target.nextSibling; + draggedEl.classList.add('slick-header-column-active'); + options.onDragStart?.(target); + createFallbackGhost(target, e.clientX, e.clientY); + fallbackActive = true; + _lastClientX = e.clientX; + document.addEventListener('mousemove', onFallbackMouseMove as EventListener); + document.addEventListener('mouseup', onFallbackMouseUp as EventListener); + }; + + const onFallbackMouseMove = (e: MouseEvent) => { + if (!fallbackActive || !draggedEl) return; + updateFallbackGhost(e.clientX, e.clientY); + const elUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; + const overDropzone = elUnder?.closest?.('.slick-dropzone'); + if (overDropzone) { + dropzoneTargetActive = true; + return; + } + dropzoneTargetActive = false; + + // If we're over another header column, perform the same live DOM reordering + // that the native dragover handler does so users get immediate visual feedback. + const targetHeader = elUnder?.closest?.('.slick-header-column') as HTMLElement | null; + if (!targetHeader || targetHeader === draggedEl || !isDraggable(targetHeader)) return; + + const targetParent = targetHeader.parentElement; + if (!targetParent || (!headerLeft.contains(targetParent) && !headerRight.contains(targetParent))) { + return; + } + + try { + const rect = targetHeader.getBoundingClientRect(); + const movingRight = _lastClientX == null ? e.clientX >= rect.left + rect.width / 2 : e.clientX > _lastClientX; + _lastClientX = e.clientX; + const insertBefore = !movingRight; + targetHeader.parentElement!.insertBefore(draggedEl, insertBefore ? targetHeader : targetHeader.nextSibling); + } catch (err) { + // ignore DOM insertion errors + } + }; + + const onFallbackMouseUp = (e: MouseEvent) => { + const draggedHeader = draggedEl; + if (draggedHeader) { + draggedHeader.classList.remove('slick-header-column-active'); + } + fallbackActive = false; + document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); + document.removeEventListener('mouseup', onFallbackMouseUp as EventListener); + + let droppedOnDropzone = dropzoneTargetActive; + try { + if (!droppedOnDropzone) { + const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; + if (el && el.closest && el.closest('.slick-dropzone')) { + droppedOnDropzone = true; + } + } + } catch (err) { + droppedOnDropzone = false; + } + // If the fallback drop landed on an external dropzone, ensure the + // header DOM is restored to its original parent so the grid's column + // list remains intact. This prevents Firefox/Linux from permanently + // removing the header element when the dropzone interaction mutates the DOM. + if (droppedOnDropzone && draggedHeader && originalParent) { + try { + originalParent.insertBefore(draggedHeader, originalNextSibling as Node | null); + } catch (err) { + // ignore restore failures + } + } + const reorderedIds = getColumnIds(headerLeft).concat(getColumnIds(headerRight)); + if (droppedOnDropzone && draggedHeader) { + options.onDrop?.(draggedHeader, e, draggedColumnId); + } else { + options.onDragEnd(reorderedIds); + } + draggedEl = null; + originalParent = null; + originalNextSibling = null; + draggedColumnId = ''; + _lastClientX = null; + clearDropzoneTarget(); + fallbackActive = false; + clearFallbackGhost(); }; for (const parent of [headerLeft, headerRight]) { parent.addEventListener('dragstart', onDragStart as EventListener); parent.addEventListener('dragover', onDragOver as EventListener); parent.addEventListener('dragend', onDragEnd as EventListener); + if (isFfLinux) { + // Mouse-based fallback for Firefox on Linux + parent.addEventListener('mousedown', onFallbackMouseDown as EventListener, true); + } } return { @@ -155,12 +420,30 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest parent.removeEventListener('dragstart', onDragStart as EventListener); parent.removeEventListener('dragover', onDragOver as EventListener); parent.removeEventListener('dragend', onDragEnd as EventListener); + if (isFfLinux) { + parent.removeEventListener('mousedown', onFallbackMouseDown as EventListener, true); + } } document.removeEventListener('drag', autoScrollHandler as EventListener); + document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); + document.removeEventListener('mouseup', onFallbackMouseUp as EventListener); + document.removeEventListener('dragenter', onDropzoneDragEnter as EventListener); + document.removeEventListener('dragleave', onDropzoneDragLeave as EventListener); + document.removeEventListener('mouseover', onDropzoneMouseOver as EventListener); + document.removeEventListener('mouseout', onDropzoneMouseOut as EventListener); stopAutoScroll(); draggedEl = null; + originalParent = null; + originalNextSibling = null; + draggedColumnId = ''; + fallbackActive = false; + clearDropzoneTarget(); + clearFallbackGhost(); [headerLeft, headerRight].forEach((parent) => - Array.from(parent.querySelectorAll('.slick-header-column')).forEach((el) => (el.draggable = false)) + Array.from(parent.querySelectorAll('.slick-header-column')).forEach((el) => { + el.draggable = false; + el.classList.remove('slick-header-column-active'); + }) ); }, }; diff --git a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts index 4f8dd111c2..d3d7355f29 100644 --- a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts +++ b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts @@ -469,6 +469,96 @@ describe('Draggable Grouping Plugin', () => { 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; diff --git a/packages/common/src/extensions/slickDraggableGrouping.ts b/packages/common/src/extensions/slickDraggableGrouping.ts index 216b7ac6ee..eb3f6e299c 100644 --- a/packages/common/src/extensions/slickDraggableGrouping.ts +++ b/packages/common/src/extensions/slickDraggableGrouping.ts @@ -307,6 +307,25 @@ export class SlickDraggableGrouping { groupTogglerElm.style.display = 'none'; } }, + onDrop: (draggedEl, _event, draggedColumnId) => { + dropzoneElm?.classList.remove('slick-dropzone-hover'); + draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); + const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); + droppedGroupingElms.forEach((el) => (el.style.display = 'flex')); + if (droppedGroupingElms.length) { + if (draggablePlaceholderElm) { + draggablePlaceholderElm.style.display = 'none'; + } + if (groupTogglerElm) { + groupTogglerElm.style.display = 'inline-flex'; + } + } + const headerColumnElm = + (draggedEl as HTMLDivElement | null) ?? this.gridContainer.querySelector(`[data-id="${draggedColumnId}"]`); + if (headerColumnElm) { + this.handleGroupByDrop(dropzoneElm, headerColumnElm); + } + }, onDragEnd: (reorderedIds) => { dropzoneElm?.classList.remove('slick-dropzone-hover'); draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); @@ -533,7 +552,10 @@ export class SlickDraggableGrouping { const dropzoneElm = this._dropzoneElm; this._dropboxDrag?.destroy(); + const isFfLinux = + typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) && /(?:linux|wayland)/i.test(navigator.userAgent); let draggedPill: HTMLElement | null = null; + let fallbackActive = false; // --- Group pill reordering within the dropzone --- const onDragStart = (e: DragEvent) => { @@ -564,10 +586,8 @@ export class SlickDraggableGrouping { } }; - const onDragEnd = () => { - if (draggedPill) { - draggedPill = null; - // Read new pill order from DOM and update columnsGroupBy + const updatePillOrder = (currentPill: HTMLElement | null = draggedPill) => { + if (currentPill) { const newGroupingOrder: Column[] = []; dropzoneElm.querySelectorAll('.slick-dropped-grouping').forEach((pillElm) => { const id = pillElm.dataset.id; @@ -579,6 +599,56 @@ export class SlickDraggableGrouping { } }; + const onDragEnd = () => { + if (draggedPill) { + const currentPill = draggedPill; + currentPill.classList.remove('slick-dropped-grouping-dragging'); + draggedPill = null; + fallbackActive = false; + updatePillOrder(currentPill); + } + }; + + const onFallbackMouseDown = (e: MouseEvent) => { + const pill = (e.target as HTMLElement).closest('.slick-dropped-grouping'); + if (!pill) { + return; + } + e.preventDefault(); + draggedPill = pill; + fallbackActive = true; + draggedPill.classList.add('slick-dropped-grouping-dragging'); + }; + + const onFallbackMouseMove = (e: MouseEvent) => { + if (!fallbackActive || !draggedPill) { + return; + } + const target = + (e.target as HTMLElement | null)?.closest('.slick-dropped-grouping') ?? + (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('.slick-dropped-grouping'); + if (!target || target === draggedPill || !dropzoneElm.contains(target)) { + return; + } + + const rect = target.getBoundingClientRect(); + const insertBefore = e.clientX < rect.left + rect.width / 2; + const insertTarget = insertBefore ? target : target.nextSibling; + if (draggedPill.parentElement === dropzoneElm && insertTarget !== draggedPill) { + dropzoneElm.insertBefore(draggedPill, insertTarget); + } + }; + + const onFallbackMouseUp = () => { + const currentPill = draggedPill; + if (currentPill) { + currentPill.classList.remove('slick-dropped-grouping-dragging'); + } + draggedPill = null; + fallbackActive = false; + updatePillOrder(currentPill); + }; + // --- Accept column header drops (creates a group pill) --- const onDragEnter = (e: DragEvent) => { if (!draggedPill) dropzoneElm.classList.add('slick-dropzone-hover'); @@ -613,6 +683,11 @@ export class SlickDraggableGrouping { dropzoneElm.addEventListener('dragenter', onDragEnter as EventListener); dropzoneElm.addEventListener('dragleave', onDragLeave as EventListener); dropzoneElm.addEventListener('drop', onDrop as EventListener); + if (isFfLinux) { + dropzoneElm.addEventListener('mousedown', onFallbackMouseDown as EventListener); + document.addEventListener('mousemove', onFallbackMouseMove as EventListener); + document.addEventListener('mouseup', onFallbackMouseUp as EventListener); + } this._dropboxDrag = { destroy: () => { @@ -622,6 +697,11 @@ export class SlickDraggableGrouping { dropzoneElm.removeEventListener('dragenter', onDragEnter as EventListener); dropzoneElm.removeEventListener('dragleave', onDragLeave as EventListener); dropzoneElm.removeEventListener('drop', onDrop as EventListener); + if (isFfLinux) { + dropzoneElm.removeEventListener('mousedown', onFallbackMouseDown as EventListener); + document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); + document.removeEventListener('mouseup', onFallbackMouseUp as EventListener); + } }, }; From 827e2fc4483eb9f044f89d427b7a8dcf9d4a9336 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 1 Jul 2026 12:09:50 -0400 Subject: [PATCH 03/19] chore: add full test coverage & move all code to custom file --- .../__tests__/slickColumnReorderDrag.spec.ts | 535 +++++++++++++++++- .../common/src/core/slickColumnReorderDrag.ts | 315 ++++++++--- packages/common/src/core/slickGrid.ts | 2 + .../__tests__/slickDraggableGrouping.spec.ts | 30 + .../src/extensions/slickDraggableGrouping.ts | 161 +----- packages/common/src/interfaces/index.ts | 1 + .../slickColumnReorderDrag.interfaces.ts | 50 ++ packages/common/src/styles/slick-grid.scss | 9 + 8 files changed, 873 insertions(+), 230 deletions(-) create mode 100644 packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts diff --git a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts index 47450a7093..1c4092ca81 100644 --- a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts +++ b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { setupColumnReorderDrag } from '../slickColumnReorderDrag.js'; +import { setupColumnReorderDrag, setupDropzonePillDrag } from '../slickColumnReorderDrag.js'; describe('slickColumnReorderDrag', () => { const createHeaderColumn = (id: string, extraClass = '') => { @@ -18,6 +18,14 @@ describe('slickColumnReorderDrag', () => { 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; + }; + afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks(); @@ -355,4 +363,529 @@ describe('slickColumnReorderDrag', () => { 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 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']); + + // mouseover path should also restore parent if moved again + foreignParent.append(firstName); + const mouseOverEvt = createMouseEvent('mouseover', { target: dropzone }); + document.dispatchEvent(mouseOverEvt); + expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName']); + + // mouseout + dragleave should clear dropzone active state and avoid onDrop + const mouseOutEvt = createMouseEvent('mouseout', { target: dropzone, relatedTarget: document.body }); + document.dispatchEvent(mouseOutEvt); + const dragLeaveEvt = createDragEvent('dragleave', { target: dropzone }); + document.dispatchEvent(dragLeaveEvt); + + firstName.dispatchEvent(createDragEvent('dragend', { clientX: 20, clientY: 20 })); + expect(onDrop).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 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() + .mockReturnValueOnce(nonDropzoneElm as Element) // mousemove -> not dropzone, keeps internal flag false + .mockReturnValueOnce(dropzone as Element); // mouseup -> should set droppedOnDropzone=true + 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).toHaveBeenCalledTimes(1); + 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); + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => { + throw new Error('boom'); + }), + }); + + 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('mouseup', { clientX: 20, clientY: 20 })); + + expect(onDrop).not.toHaveBeenCalled(); + expect(onDragEnd).toHaveBeenCalledWith(['firstName']); + + 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 }); + }); }); diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index c081cad5b7..f5cba7147c 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -1,33 +1,5 @@ import { getOffset } from '@slickgrid-universal/utils'; - -export interface ColumnReorderDragOption { - /** 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; - /** - * 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, draggedColumnId?: string) => void; -} +import type { ColumnReorderDragOption, DropzonePillDragOption } from '../interfaces/slickColumnReorderDrag.interfaces.js'; /** * Sets up native HTML5 drag-and-drop for column header reordering. @@ -43,6 +15,8 @@ export interface ColumnReorderDragOption { */ 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'; let columnScrollTimer: ReturnType | undefined; let draggedEl: HTMLElement | null = null; @@ -62,7 +36,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest }; const isDraggable = (el: HTMLElement): boolean => - el.classList.contains('slick-header-column') && (!unorderableColumnCssClass || !el.classList.contains(unorderableColumnCssClass)); + 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. // Include Wayland because Firefox on Linux Wayland may omit the X11 token. @@ -78,7 +52,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest // Set draggable attribute on all eligible header columns const refreshDraggable = (parent: HTMLElement) => { Array.from(parent.children as HTMLCollectionOf).forEach((el) => { - if (el.classList.contains('slick-header-column')) { + if (el.matches(draggableSelector)) { // Disable native HTML5 drag on Firefox/Linux and use mouse fallback el.draggable = isDraggable(el) && !isFfLinux; } @@ -115,24 +89,18 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest }; const updateFallbackGhost = (clientX: number, clientY: number) => { - if (!dragGhost) { - return; + if (dragGhost) { + dragGhost.style.left = `${clientX}px`; + dragGhost.style.top = `${clientY}px`; } - dragGhost.style.left = `${clientX}px`; - dragGhost.style.top = `${clientY}px`; }; const createFallbackGhost = (source: HTMLElement, clientX: number, clientY: number) => { clearFallbackGhost(); dragGhost = source.cloneNode(true) as HTMLElement; dragGhost.classList.add('slick-header-column-drag-ghost'); - dragGhost.style.position = 'fixed'; dragGhost.style.left = `${clientX}px`; dragGhost.style.top = `${clientY}px`; - dragGhost.style.margin = '0'; - dragGhost.style.pointerEvents = 'none'; - dragGhost.style.zIndex = '9999'; - dragGhost.style.transform = 'translate(-8px, -8px)'; document.body.appendChild(dragGhost); }; @@ -186,7 +154,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest document.addEventListener('mouseout', onDropzoneMouseOut as EventListener); const onDragStart = (e: DragEvent) => { - const target = (e.target as HTMLElement).closest('.slick-header-column'); + const target = (e.target as HTMLElement).closest(draggableSelector); if (!target || !isDraggable(target)) { e.preventDefault(); return; @@ -197,7 +165,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest // remember original position so we can restore it if dropped outside headers (eg. dropzone) originalParent = target.parentElement; originalNextSibling = target.nextSibling; - target.classList.add('slick-header-column-active'); + target.classList.add(dragActiveClass); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; // Store column id so the dropzone can identify which column was dragged @@ -242,7 +210,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest return; } - const target = (e.target as HTMLElement).closest('.slick-header-column'); + const target = (e.target as HTMLElement).closest(draggableSelector); if (!draggedEl || !target || target === draggedEl || !isDraggable(target)) { return; } @@ -263,7 +231,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const onDragEnd = (e: DragEvent) => { const draggedHeader = draggedEl; if (draggedHeader) { - draggedHeader.classList.remove('slick-header-column-active'); + draggedHeader.classList.remove(dragActiveClass); } stopAutoScroll(); document.removeEventListener('drag', autoScrollHandler as EventListener); @@ -309,58 +277,62 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest // Firefox on Linux has broken/unstable native HTML5 drag behavior in many setups. // Provide a mouse-based fallback that mimics SortableJS's `forceFallback`. const onFallbackMouseDown = (e: MouseEvent) => { - const target = (e.target as HTMLElement).closest('.slick-header-column'); - if (!target || !isDraggable(target)) return; - e.preventDefault(); - draggedEl = target; - draggedColumnId = target.dataset.id ?? ''; - originalParent = target.parentElement; - originalNextSibling = target.nextSibling; - draggedEl.classList.add('slick-header-column-active'); - options.onDragStart?.(target); - createFallbackGhost(target, e.clientX, e.clientY); - fallbackActive = true; - _lastClientX = e.clientX; - document.addEventListener('mousemove', onFallbackMouseMove as EventListener); - document.addEventListener('mouseup', onFallbackMouseUp as EventListener); + const target = (e.target as HTMLElement).closest(draggableSelector); + if (target && isDraggable(target)) { + e.preventDefault(); + draggedEl = target; + draggedColumnId = target.dataset.id ?? ''; + originalParent = target.parentElement; + originalNextSibling = target.nextSibling; + draggedEl.classList.add(dragActiveClass); + options.onDragStart?.(target); + createFallbackGhost(target, e.clientX, e.clientY); + fallbackActive = true; + _lastClientX = e.clientX; + document.addEventListener('mousemove', onFallbackMouseMove as EventListener); + document.addEventListener('mouseup', onFallbackMouseUp as EventListener); + } }; const onFallbackMouseMove = (e: MouseEvent) => { - if (!fallbackActive || !draggedEl) return; - updateFallbackGhost(e.clientX, e.clientY); - const elUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; - const overDropzone = elUnder?.closest?.('.slick-dropzone'); - if (overDropzone) { - dropzoneTargetActive = true; - return; - } - dropzoneTargetActive = false; + if (fallbackActive && draggedEl) { + updateFallbackGhost(e.clientX, e.clientY); + const elUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; + const overDropzone = elUnder?.closest?.('.slick-dropzone'); + if (overDropzone) { + dropzoneTargetActive = true; + return; + } + dropzoneTargetActive = false; - // If we're over another header column, perform the same live DOM reordering - // that the native dragover handler does so users get immediate visual feedback. - const targetHeader = elUnder?.closest?.('.slick-header-column') as HTMLElement | null; - if (!targetHeader || targetHeader === draggedEl || !isDraggable(targetHeader)) return; + // If we're over another header column, perform the same live DOM reordering + // that the native dragover handler does so users get immediate visual feedback. + const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; + if (!targetHeader || targetHeader === draggedEl || !isDraggable(targetHeader)) { + return; + } - const targetParent = targetHeader.parentElement; - if (!targetParent || (!headerLeft.contains(targetParent) && !headerRight.contains(targetParent))) { - return; - } + const targetParent = targetHeader.parentElement; + if (!targetParent || (!headerLeft.contains(targetParent) && !headerRight.contains(targetParent))) { + return; + } - try { - const rect = targetHeader.getBoundingClientRect(); - const movingRight = _lastClientX == null ? e.clientX >= rect.left + rect.width / 2 : e.clientX > _lastClientX; - _lastClientX = e.clientX; - const insertBefore = !movingRight; - targetHeader.parentElement!.insertBefore(draggedEl, insertBefore ? targetHeader : targetHeader.nextSibling); - } catch (err) { - // ignore DOM insertion errors + try { + const rect = targetHeader.getBoundingClientRect(); + const movingRight = _lastClientX == null ? e.clientX >= rect.left + rect.width / 2 : e.clientX > _lastClientX; + _lastClientX = e.clientX; + const insertBefore = !movingRight; + targetHeader.parentElement!.insertBefore(draggedEl, insertBefore ? targetHeader : targetHeader.nextSibling); + } catch (err) { + // ignore DOM insertion errors + } } }; const onFallbackMouseUp = (e: MouseEvent) => { const draggedHeader = draggedEl; if (draggedHeader) { - draggedHeader.classList.remove('slick-header-column-active'); + draggedHeader.classList.remove(dragActiveClass); } fallbackActive = false; document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); @@ -370,7 +342,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest try { if (!droppedOnDropzone) { const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; - if (el && el.closest && el.closest('.slick-dropzone')) { + if (el?.closest?.('.slick-dropzone')) { droppedOnDropzone = true; } } @@ -440,11 +412,180 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest clearDropzoneTarget(); clearFallbackGhost(); [headerLeft, headerRight].forEach((parent) => - Array.from(parent.querySelectorAll('.slick-header-column')).forEach((el) => { + Array.from(parent.querySelectorAll(draggableSelector)).forEach((el) => { el.draggable = false; - el.classList.remove('slick-header-column-active'); + 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 userAgent = (typeof window !== 'undefined' ? window.navigator : navigator)?.userAgent ?? ''; + const isFfLinux = /firefox/i.test(userAgent) && /(?:linux|wayland)/i.test(userAgent); + + let draggedPill: HTMLElement | null = null; + let fallbackActive = false; + + // ── 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 mouse-based fallback for pill reordering ──────────────── + + const onFallbackMouseDown = (e: MouseEvent) => { + const pill = (e.target as HTMLElement).closest(itemSelector); + if (pill) { + e.preventDefault(); + draggedPill = pill; + fallbackActive = true; + if (draggingCssClass) { + pill.classList.add(draggingCssClass); + } + } + }; + + const onFallbackMouseMove = (e: MouseEvent) => { + if (fallbackActive && draggedPill) { + // e.target may be Document (not an Element) when dispatched on document, so guard with instanceof + const targetEl = e.target instanceof Element ? (e.target as HTMLElement) : null; + const target = + targetEl?.closest(itemSelector) ?? + (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(itemSelector); + if (!target || target === draggedPill || !dropzoneElm.contains(target)) { + return; + } + const rect = target.getBoundingClientRect(); + const insertBefore = e.clientX < rect.left + rect.width / 2; + const insertTarget = insertBefore ? target : target.nextSibling; + if (draggedPill.parentElement === dropzoneElm && insertTarget !== draggedPill) { + dropzoneElm.insertBefore(draggedPill, insertTarget); + } + } + }; + + const onFallbackMouseUp = () => { + const currentPill = draggedPill; + if (currentPill && draggingCssClass) { + currentPill.classList.remove(draggingCssClass); + } + draggedPill = null; + fallbackActive = false; + 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', onFallbackMouseDown as EventListener); + document.addEventListener('mousemove', onFallbackMouseMove as EventListener); + document.addEventListener('mouseup', onFallbackMouseUp); + } + + 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); + if (isFfLinux) { + dropzoneElm.removeEventListener('mousedown', onFallbackMouseDown as EventListener); + document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); + document.removeEventListener('mouseup', onFallbackMouseUp); + } + draggedPill = null; + fallbackActive = false; + }, + }; +} diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index f1024fb5af..2040672d8d 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -2148,6 +2148,8 @@ export class SlickGrid = Column, O e 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); diff --git a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts index d3d7355f29..bd6e3ce7be 100644 --- a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts +++ b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts @@ -383,6 +383,36 @@ describe('Draggable Grouping Plugin', () => { 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' }); diff --git a/packages/common/src/extensions/slickDraggableGrouping.ts b/packages/common/src/extensions/slickDraggableGrouping.ts index eb3f6e299c..3f8ff7ffb9 100644 --- a/packages/common/src/extensions/slickDraggableGrouping.ts +++ b/packages/common/src/extensions/slickDraggableGrouping.ts @@ -2,7 +2,7 @@ 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 { SlickEvent, SlickEventData, SlickEventHandler, type SlickDataView, type SlickGrid } from '../core/index.js'; -import { setupColumnReorderDrag } from '../core/slickColumnReorderDrag.js'; +import { setupColumnReorderDrag, setupDropzonePillDrag } from '../core/slickColumnReorderDrag.js'; import { SortDirectionNumber } from '../enums/index.js'; import type { Column, @@ -298,6 +298,7 @@ export class SlickDraggableGrouping { container: this.gridContainer, viewportScrollContainerX: (grid as any).getViewportNode?.() ?? this.gridContainer, hasFrozenColumns: () => ((grid.getOptions?.() as GridOption | undefined)?.frozenColumn ?? -1) >= 0, + draggableSelector: '.slick-header-column', onDragStart: () => { if (draggablePlaceholderElm) { draggablePlaceholderElm.style.display = 'inline-block'; @@ -552,42 +553,11 @@ export class SlickDraggableGrouping { const dropzoneElm = this._dropzoneElm; this._dropboxDrag?.destroy(); - const isFfLinux = - typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) && /(?:linux|wayland)/i.test(navigator.userAgent); - let draggedPill: HTMLElement | null = null; - let fallbackActive = false; - - // --- Group pill reordering within the dropzone --- - const onDragStart = (e: DragEvent) => { - const pill = (e.target as HTMLElement).closest('.slick-dropped-grouping'); - if (pill) { - draggedPill = pill; - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - // Firefox requires setData() to be called in dragstart, otherwise the drag won't initiate - if (typeof e.dataTransfer.setData === 'function') { - e.dataTransfer.setData('text/plain', pill.dataset.id ?? ''); - } - // Explicit drag image avoids Firefox+Linux X11 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(); // allow drop in all cases - const target = (e.target as HTMLElement).closest('.slick-dropped-grouping'); - if (draggedPill && target && target !== draggedPill) { - const rect = target.getBoundingClientRect(); - dropzoneElm.insertBefore(draggedPill, e.clientX < rect.left + rect.width / 2 ? target : target.nextSibling); - } - }; - - const updatePillOrder = (currentPill: HTMLElement | null = draggedPill) => { - if (currentPill) { + this._dropboxDrag = setupDropzonePillDrag({ + dropzoneElm, + itemSelector: '.slick-dropped-grouping', + draggingCssClass: 'slick-dropped-grouping-dragging', + onPillDragEnd: () => { const newGroupingOrder: Column[] = []; dropzoneElm.querySelectorAll('.slick-dropped-grouping').forEach((pillElm) => { const id = pillElm.dataset.id; @@ -596,114 +566,21 @@ export class SlickDraggableGrouping { }); this.columnsGroupBy = newGroupingOrder; this.updateGroupBy('sort-group'); - } - }; - - const onDragEnd = () => { - if (draggedPill) { - const currentPill = draggedPill; - currentPill.classList.remove('slick-dropped-grouping-dragging'); - draggedPill = null; - fallbackActive = false; - updatePillOrder(currentPill); - } - }; - - const onFallbackMouseDown = (e: MouseEvent) => { - const pill = (e.target as HTMLElement).closest('.slick-dropped-grouping'); - if (!pill) { - return; - } - e.preventDefault(); - draggedPill = pill; - fallbackActive = true; - draggedPill.classList.add('slick-dropped-grouping-dragging'); - }; - - const onFallbackMouseMove = (e: MouseEvent) => { - if (!fallbackActive || !draggedPill) { - return; - } - const target = - (e.target as HTMLElement | null)?.closest('.slick-dropped-grouping') ?? - (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('.slick-dropped-grouping'); - if (!target || target === draggedPill || !dropzoneElm.contains(target)) { - return; - } - - const rect = target.getBoundingClientRect(); - const insertBefore = e.clientX < rect.left + rect.width / 2; - const insertTarget = insertBefore ? target : target.nextSibling; - if (draggedPill.parentElement === dropzoneElm && insertTarget !== draggedPill) { - dropzoneElm.insertBefore(draggedPill, insertTarget); - } - }; - - const onFallbackMouseUp = () => { - const currentPill = draggedPill; - if (currentPill) { - currentPill.classList.remove('slick-dropped-grouping-dragging'); - } - draggedPill = null; - fallbackActive = false; - updatePillOrder(currentPill); - }; - - // --- Accept column header drops (creates a group pill) --- - const onDragEnter = (e: DragEvent) => { - if (!draggedPill) dropzoneElm.classList.add('slick-dropzone-hover'); - e.preventDefault(); - }; - - const onDragLeave = (e: DragEvent) => { - // Only remove hover when cursor actually leaves the dropzone, not just entering a child - if (!draggedPill && !dropzoneElm.contains(e.relatedTarget as Node)) { - dropzoneElm.classList.remove('slick-dropzone-hover'); - } - }; - - const onDrop = (e: DragEvent) => { - e.preventDefault(); - dropzoneElm.classList.remove('slick-dropzone-hover'); - if (!draggedPill) { - // Pill reorder is finalized in dragend, so only handle header drops when no pill drag is active. - const columnDataId = e.dataTransfer?.getData('text/plain'); - if (columnDataId) { - const headerColumnElm = this.gridContainer.querySelector(`[data-id="${columnDataId}"]`); - if (headerColumnElm) { - this.handleGroupByDrop(dropzoneElm, headerColumnElm); - } + }, + onColumnDragEnter: () => dropzoneElm.classList.add('slick-dropzone-hover'), + onColumnDragLeave: (e) => { + if (!dropzoneElm.contains(e.relatedTarget as Node)) { + dropzoneElm.classList.remove('slick-dropzone-hover'); } - } - }; - - 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', onFallbackMouseDown as EventListener); - document.addEventListener('mousemove', onFallbackMouseMove as EventListener); - document.addEventListener('mouseup', onFallbackMouseUp as EventListener); - } - - this._dropboxDrag = { - 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); - if (isFfLinux) { - dropzoneElm.removeEventListener('mousedown', onFallbackMouseDown as EventListener); - document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); - document.removeEventListener('mouseup', onFallbackMouseUp as EventListener); + }, + onColumnDrop: (columnDataId) => { + dropzoneElm.classList.remove('slick-dropzone-hover'); + const headerColumnElm = this.gridContainer.querySelector(`[data-id="${columnDataId}"]`); + if (headerColumnElm) { + this.handleGroupByDrop(dropzoneElm, headerColumnElm); } }, - }; + }); // Keep the placeholder-level hover listeners (for visual feedback when cursor enters the placeholder) this.addDragOverDropzoneListeners(); 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..bd642fcaac --- /dev/null +++ b/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts @@ -0,0 +1,50 @@ +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; + /** + * 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, 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..010a787ea1 100644 --- a/packages/common/src/styles/slick-grid.scss +++ b/packages/common/src/styles/slick-grid.scss @@ -100,6 +100,15 @@ background-color: var(--slick-header-column-background-active, v.$slick-header-column-background-active) !important; } + // 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); + } + .slick-headerrow { background: var(--slick-grid-header-background, v.$slick-grid-header-background); } From 14c1d270a78ae009e12d083a1c74cff58f6f1123 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 1 Jul 2026 13:58:15 -0400 Subject: [PATCH 04/19] chore: final AI code review & add Safari fallbacks for safety --- .../__tests__/slickColumnReorderDrag.spec.ts | 51 +++++++++++ .../common/src/core/slickColumnReorderDrag.ts | 84 +++++++++---------- .../__tests__/slickDraggableGrouping.spec.ts | 38 +++++++++ .../src/extensions/slickDraggableGrouping.ts | 5 +- 4 files changed, 135 insertions(+), 43 deletions(-) diff --git a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts index 1c4092ca81..e278976302 100644 --- a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts +++ b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts @@ -592,6 +592,57 @@ describe('slickColumnReorderDrag', () => { expect(onDrop).not.toHaveBeenCalled(); }); + it('should keep dropzone active on mouseout 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 })); + + const mouseOutEvt = createMouseEvent('mouseout', { target: dropzone, relatedTarget: null, clientX: 25, clientY: 25 }); + document.dispatchEvent(mouseOutEvt); + + 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'); diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index f5cba7147c..240037f641 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -35,6 +35,31 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest columnScrollTimer = undefined; }; + const restoreDraggedToOriginalParent = () => { + if (originalParent && draggedEl && draggedEl.parentElement !== originalParent) { + originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); + } + }; + + const isOverDropzone = (el: HTMLElement | null | undefined): boolean => !!el?.closest?.('.slick-dropzone'); + + const reorderDraggedAgainstTarget = (target: HTMLElement, clientX: number) => { + if (!draggedEl || target === draggedEl || !isDraggable(target)) { + return; + } + + const targetParent = target.parentElement; + if (!targetParent || (!headerLeft.contains(targetParent) && !headerRight.contains(targetParent))) { + 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)); @@ -63,7 +88,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const autoScrollHandler = (e: DragEvent) => { const { clientX, clientY, pageX } = e; - if (clientX && clientY) { + if (clientX != null && clientY != null) { const containerOffset = getOffset(container); const viewportLeft = getOffset(viewportScrollContainerX).left; const containerRight = containerOffset.left + container.clientWidth; @@ -106,13 +131,11 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const onDropzoneDragEnter = (e: Event) => { const target = e.target as HTMLElement | null; - if (draggedEl && target?.closest?.('.slick-dropzone')) { + if (draggedEl && isOverDropzone(target)) { dropzoneTargetActive = true; // Ensure the dragged header remains in the header DOM (non-destructive) try { - if (originalParent && draggedEl.parentElement !== originalParent) { - originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); - } + restoreDraggedToOriginalParent(); } catch (err) { // ignore } @@ -121,19 +144,17 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const onDropzoneDragLeave = (e: Event) => { const target = e.target as HTMLElement | null; - if (target?.closest?.('.slick-dropzone')) { + if (isOverDropzone(target)) { dropzoneTargetActive = false; } }; const onDropzoneMouseOver = (e: MouseEvent) => { const target = e.target as HTMLElement | null; - if (draggedEl && target?.closest?.('.slick-dropzone')) { + if (draggedEl && isOverDropzone(target)) { dropzoneTargetActive = true; try { - if (originalParent && draggedEl.parentElement !== originalParent) { - originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); - } + restoreDraggedToOriginalParent(); } catch (err) { // ignore } @@ -142,8 +163,10 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const onDropzoneMouseOut = (e: MouseEvent) => { const target = e.target as HTMLElement | null; - const relatedTarget = e.relatedTarget as HTMLElement | null; - if (target?.closest?.('.slick-dropzone') && !relatedTarget?.closest?.('.slick-dropzone')) { + 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; } }; @@ -196,14 +219,12 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const onDragOver = (e: DragEvent) => { e.preventDefault(); - const overDropzone = (e.target as HTMLElement | null)?.closest?.('.slick-dropzone'); + const overDropzone = isOverDropzone(e.target as HTMLElement | null); if (overDropzone) { dropzoneTargetActive = true; // Keep the dragged header visible in the original header DOM while over the dropzone try { - if (originalParent && draggedEl && draggedEl.parentElement !== originalParent) { - originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); - } + restoreDraggedToOriginalParent(); } catch (err) { // ignore } @@ -211,21 +232,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } const target = (e.target as HTMLElement).closest(draggableSelector); - if (!draggedEl || !target || target === draggedEl || !isDraggable(target)) { - return; + if (target) { + reorderDraggedAgainstTarget(target, e.clientX); } - - // Only reorder against actual header containers, never against pre-header content. - const targetParent = target.parentElement; - if (!targetParent || (!headerLeft.contains(targetParent) && !headerRight.contains(targetParent))) { - return; - } - - const rect = target.getBoundingClientRect(); - const movingRight = _lastClientX == null ? e.clientX >= rect.left + rect.width / 2 : e.clientX > _lastClientX; - _lastClientX = e.clientX; - const insertBefore = !movingRight; - target.parentElement!.insertBefore(draggedEl, insertBefore ? target : target.nextSibling); }; const onDragEnd = (e: DragEvent) => { @@ -298,7 +307,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest if (fallbackActive && draggedEl) { updateFallbackGhost(e.clientX, e.clientY); const elUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; - const overDropzone = elUnder?.closest?.('.slick-dropzone'); + const overDropzone = isOverDropzone(elUnder); if (overDropzone) { dropzoneTargetActive = true; return; @@ -308,21 +317,12 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest // If we're over another header column, perform the same live DOM reordering // that the native dragover handler does so users get immediate visual feedback. const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; - if (!targetHeader || targetHeader === draggedEl || !isDraggable(targetHeader)) { - return; - } - - const targetParent = targetHeader.parentElement; - if (!targetParent || (!headerLeft.contains(targetParent) && !headerRight.contains(targetParent))) { + if (!targetHeader) { return; } try { - const rect = targetHeader.getBoundingClientRect(); - const movingRight = _lastClientX == null ? e.clientX >= rect.left + rect.width / 2 : e.clientX > _lastClientX; - _lastClientX = e.clientX; - const insertBefore = !movingRight; - targetHeader.parentElement!.insertBefore(draggedEl, insertBefore ? targetHeader : targetHeader.nextSibling); + reorderDraggedAgainstTarget(targetHeader, e.clientX); } catch (err) { // ignore DOM insertion errors } diff --git a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts index bd6e3ce7be..1322344a55 100644 --- a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts +++ b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts @@ -689,6 +689,44 @@ describe('Draggable Grouping Plugin', () => { plugin.setDroppedGroups('age'); }); + 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; diff --git a/packages/common/src/extensions/slickDraggableGrouping.ts b/packages/common/src/extensions/slickDraggableGrouping.ts index 3f8ff7ffb9..95214c1a09 100644 --- a/packages/common/src/extensions/slickDraggableGrouping.ts +++ b/packages/common/src/extensions/slickDraggableGrouping.ts @@ -569,7 +569,10 @@ export class SlickDraggableGrouping { }, onColumnDragEnter: () => dropzoneElm.classList.add('slick-dropzone-hover'), onColumnDragLeave: (e) => { - if (!dropzoneElm.contains(e.relatedTarget as Node)) { + 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('slick-dropzone-hover'); } }, From 2ee92cb271ff3c5a9be1dbe5cd8f5214b6d3d8c2 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 11:32:31 -0400 Subject: [PATCH 05/19] chore: add touch support & merge event callbacks when possible --- .../__tests__/slickColumnReorderDrag.spec.ts | 369 ++++++++++++++++++ .../common/src/core/slickColumnReorderDrag.ts | 294 ++++++++------ .../slickColumnReorderDrag.interfaces.ts | 2 +- 3 files changed, 539 insertions(+), 126 deletions(-) diff --git a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts index e278976302..692ad49ad7 100644 --- a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts +++ b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts @@ -26,6 +26,14 @@ describe('slickColumnReorderDrag', () => { 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(); @@ -459,6 +467,118 @@ describe('slickColumnReorderDrag', () => { 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(); + }); + it('should keep working when elementFromPoint throws in dragend and still call onDragEnd', () => { const headerLeft = document.createElement('div'); const headerRight = document.createElement('div'); @@ -939,4 +1059,253 @@ describe('slickColumnReorderDrag', () => { 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/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 240037f641..08414dd266 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -176,47 +176,6 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest document.addEventListener('mouseover', onDropzoneMouseOver as EventListener); document.addEventListener('mouseout', onDropzoneMouseOut as EventListener); - const onDragStart = (e: DragEvent) => { - const target = (e.target as HTMLElement).closest(draggableSelector); - if (!target || !isDraggable(target)) { - e.preventDefault(); - return; - } - draggedEl = target; - draggedColumnId = target.dataset.id ?? ''; - clearDropzoneTarget(); - // remember original position so we can restore it if dropped outside headers (eg. dropzone) - originalParent = target.parentElement; - originalNextSibling = target.nextSibling; - target.classList.add(dragActiveClass); - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - // Store column id so the dropzone can identify which column was dragged - if (typeof e.dataTransfer.setData === 'function') { - e.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 e.dataTransfer.setDragImage === 'function') { - const rect = target.getBoundingClientRect(); - e.dataTransfer.setDragImage(target, e.clientX - rect.left, e.clientY - rect.top); - } - } - - options.onDragStart?.(target); - - // reset pointer tracking for this drag session - _lastClientX = e.clientX ?? null; - - // 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); - } - }; - const onDragOver = (e: DragEvent) => { e.preventDefault(); const overDropzone = isOverDropzone(e.target as HTMLElement | null); @@ -283,81 +242,141 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest _lastClientX = null; }; - // Firefox on Linux has broken/unstable native HTML5 drag behavior in many setups. - // Provide a mouse-based fallback that mimics SortableJS's `forceFallback`. - const onFallbackMouseDown = (e: MouseEvent) => { + // Firefox/Linux native HTML5 drag is broken; touch screens never fire HTML5 drag events. + // All three start events (dragstart, mousedown, touchstart) share one handler. + + /** 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 }; + }; + + const onStart = (e: DragEvent | MouseEvent | TouchEvent) => { + const { clientX, clientY } = getPointerPos(e); const target = (e.target as HTMLElement).closest(draggableSelector); - if (target && isDraggable(target)) { + if (!target || !isDraggable(target)) { + // Cancel a native drag that started on a non-orderable column + if (e.type === 'dragstart') { + e.preventDefault(); + } + return; + } + // Common state setup + draggedEl = target; + draggedColumnId = target.dataset.id ?? ''; + clearDropzoneTarget(); + originalParent = target.parentElement; + originalNextSibling = target.nextSibling; + target.classList.add(dragActiveClass); + options.onDragStart?.(target); + _lastClientX = clientX; + + if (e.type === 'dragstart') { + // Native HTML5 drag: configure dataTransfer and auto-scroll + 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) e.preventDefault(); - draggedEl = target; - draggedColumnId = target.dataset.id ?? ''; - originalParent = target.parentElement; - originalNextSibling = target.nextSibling; - draggedEl.classList.add(dragActiveClass); - options.onDragStart?.(target); - createFallbackGhost(target, e.clientX, e.clientY); + createFallbackGhost(target, clientX, clientY); fallbackActive = true; - _lastClientX = e.clientX; - document.addEventListener('mousemove', onFallbackMouseMove as EventListener); - document.addEventListener('mouseup', onFallbackMouseUp as EventListener); + 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 onFallbackMouseMove = (e: MouseEvent) => { + const onPointerMove = (e: MouseEvent | TouchEvent) => { if (fallbackActive && draggedEl) { - updateFallbackGhost(e.clientX, e.clientY); - const elUnder = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; - const overDropzone = isOverDropzone(elUnder); - if (overDropzone) { - dropzoneTargetActive = true; - return; - } - dropzoneTargetActive = false; + e.preventDefault(); + const { clientX, clientY, pageX } = getPointerPos(e); + updateFallbackGhost(clientX, clientY); - // If we're over another header column, perform the same live DOM reordering - // that the native dragover handler does so users get immediate visual feedback. - const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; - if (!targetHeader) { - return; + // 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, 100); + } else if (!columnScrollTimer && pageX < viewportLeft) { + columnScrollTimer = setInterval(scrollColumnsLeft, 100); + } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { + stopAutoScroll(); } - try { - reorderDraggedAgainstTarget(targetHeader, e.clientX); - } catch (err) { - // ignore DOM insertion errors + const elUnder = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + const overDropzone = isOverDropzone(elUnder); + if (overDropzone) { + dropzoneTargetActive = true; + } else { + dropzoneTargetActive = false; + const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; + if (targetHeader) { + try { + reorderDraggedAgainstTarget(targetHeader, clientX); + } catch (err) { + // ignore DOM insertion errors + } + } } } }; - const onFallbackMouseUp = (e: MouseEvent) => { + const onPointerUp = (e: MouseEvent | TouchEvent) => { + 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 draggedHeader = draggedEl; - if (draggedHeader) { - draggedHeader.classList.remove(dragActiveClass); - } + if (draggedHeader) draggedHeader.classList.remove(dragActiveClass); + stopAutoScroll(); fallbackActive = false; - document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); - document.removeEventListener('mouseup', onFallbackMouseUp as EventListener); + const { clientX, clientY } = getPointerPos(e); let droppedOnDropzone = dropzoneTargetActive; try { if (!droppedOnDropzone) { - const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; - if (el?.closest?.('.slick-dropzone')) { - droppedOnDropzone = true; - } + const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + if (el?.closest?.('.slick-dropzone')) droppedOnDropzone = true; } } catch (err) { droppedOnDropzone = false; } - // If the fallback drop landed on an external dropzone, ensure the - // header DOM is restored to its original parent so the grid's column - // list remains intact. This prevents Firefox/Linux from permanently - // removing the header element when the dropzone interaction mutates the DOM. if (droppedOnDropzone && draggedHeader && originalParent) { try { originalParent.insertBefore(draggedHeader, originalNextSibling as Node | null); } catch (err) { - // ignore restore failures + // ignore } } const reorderedIds = getColumnIds(headerLeft).concat(getColumnIds(headerRight)); @@ -377,28 +396,34 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest }; for (const parent of [headerLeft, headerRight]) { - parent.addEventListener('dragstart', onDragStart as EventListener); + parent.addEventListener('dragstart', onStart as EventListener); parent.addEventListener('dragover', onDragOver as EventListener); parent.addEventListener('dragend', onDragEnd as EventListener); if (isFfLinux) { // Mouse-based fallback for Firefox on Linux - parent.addEventListener('mousedown', onFallbackMouseDown as EventListener, true); + 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', onDragStart as EventListener); + parent.removeEventListener('dragstart', onStart as EventListener); parent.removeEventListener('dragover', onDragOver as EventListener); parent.removeEventListener('dragend', onDragEnd as EventListener); if (isFfLinux) { - parent.removeEventListener('mousedown', onFallbackMouseDown as EventListener, true); + parent.removeEventListener('mousedown', onStart as EventListener, true); } + parent.removeEventListener('touchstart', onStart as EventListener); } document.removeEventListener('drag', autoScrollHandler as EventListener); - document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); - document.removeEventListener('mouseup', onFallbackMouseUp as EventListener); + 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); document.removeEventListener('dragenter', onDropzoneDragEnter as EventListener); document.removeEventListener('dragleave', onDropzoneDragLeave as EventListener); document.removeEventListener('mouseover', onDropzoneMouseOver as EventListener); @@ -511,49 +536,64 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro } }; - // ── Firefox/Linux mouse-based fallback for pill reordering ──────────────── + // 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 onFallbackMouseDown = (e: MouseEvent) => { + /** Extract { clientX, clientY } from a mouse or touch event. + * For touchend/touchcancel, e.touches is empty – falls back to changedTouches. */ + const getPointerPos = (e: MouseEvent | TouchEvent) => { + if ('touches' in e) { + const t = e.touches[0] ?? e.changedTouches[0]; + return { clientX: t?.clientX ?? 0, clientY: t?.clientY ?? 0 }; + } + return { clientX: e.clientX, clientY: e.clientY }; + }; + + const onPointerDown = (e: MouseEvent | TouchEvent) => { const pill = (e.target as HTMLElement).closest(itemSelector); if (pill) { e.preventDefault(); draggedPill = pill; fallbackActive = true; - if (draggingCssClass) { - pill.classList.add(draggingCssClass); + if (draggingCssClass) pill.classList.add(draggingCssClass); + 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 onFallbackMouseMove = (e: MouseEvent) => { + const onPointerMove = (e: MouseEvent | TouchEvent) => { if (fallbackActive && draggedPill) { - // e.target may be Document (not an Element) when dispatched on document, so guard with instanceof - const targetEl = e.target instanceof Element ? (e.target as HTMLElement) : null; - const target = - targetEl?.closest(itemSelector) ?? - (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(itemSelector); - if (!target || target === draggedPill || !dropzoneElm.contains(target)) { - return; - } - const rect = target.getBoundingClientRect(); - const insertBefore = e.clientX < rect.left + rect.width / 2; - const insertTarget = insertBefore ? target : target.nextSibling; - if (draggedPill.parentElement === dropzoneElm && insertTarget !== draggedPill) { - dropzoneElm.insertBefore(draggedPill, insertTarget); + e.preventDefault(); + const { clientX, clientY } = getPointerPos(e); + 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 onFallbackMouseUp = () => { + const onPointerUp = () => { + 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 currentPill = draggedPill; - if (currentPill && draggingCssClass) { - currentPill.classList.remove(draggingCssClass); - } + if (currentPill && draggingCssClass) currentPill.classList.remove(draggingCssClass); draggedPill = null; fallbackActive = false; - if (currentPill) { - options.onPillDragEnd?.(currentPill); - } + if (currentPill) options.onPillDragEnd?.(currentPill); }; // ── Register listeners ──────────────────────────────────────────────────── @@ -566,10 +606,10 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro dropzoneElm.addEventListener('drop', onDrop as EventListener); if (isFfLinux) { - dropzoneElm.addEventListener('mousedown', onFallbackMouseDown as EventListener); - document.addEventListener('mousemove', onFallbackMouseMove as EventListener); - document.addEventListener('mouseup', onFallbackMouseUp); + dropzoneElm.addEventListener('mousedown', onPointerDown as EventListener); } + // Touch support for pill reordering (all platforms) + dropzoneElm.addEventListener('touchstart', onPointerDown as EventListener, { passive: false }); return { destroy() { @@ -579,11 +619,15 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro 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', onFallbackMouseDown as EventListener); - document.removeEventListener('mousemove', onFallbackMouseMove as EventListener); - document.removeEventListener('mouseup', onFallbackMouseUp); + dropzoneElm.removeEventListener('mousedown', onPointerDown as EventListener); } + 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); draggedPill = null; fallbackActive = false; }, diff --git a/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts b/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts index bd642fcaac..39907bb843 100644 --- a/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts +++ b/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts @@ -28,7 +28,7 @@ export interface ColumnReorderDragOption { /** * Called when the drag is dropped onto an external dropzone such as draggable grouping. */ - onDrop?: (draggedEl: HTMLElement, event: DragEvent | MouseEvent, draggedColumnId?: string) => void; + onDrop?: (draggedEl: HTMLElement, event: DragEvent | MouseEvent | TouchEvent, draggedColumnId?: string) => void; } /** Dropzone pill drag (used by DraggableGrouping to reorder group pills) */ From c5d351a48583d831a9539c7f2ebbef0cfaaa0c32 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 11:46:20 -0400 Subject: [PATCH 06/19] chore: stop click event from bubbling up --- packages/common/src/core/slickColumnReorderDrag.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 08414dd266..a4a98c9950 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -302,6 +302,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } else { // Pointer fallback (mouse on FF/Linux, touch on all platforms) e.preventDefault(); + e.stopPropagation(); createFallbackGhost(target, clientX, clientY); fallbackActive = true; if ('touches' in e) { @@ -553,6 +554,7 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro const pill = (e.target as HTMLElement).closest(itemSelector); if (pill) { e.preventDefault(); + e.stopPropagation(); draggedPill = pill; fallbackActive = true; if (draggingCssClass) pill.classList.add(draggingCssClass); From a1ad126438c336935ce738377f77b0f5a88ed7c3 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 11:52:53 -0400 Subject: [PATCH 07/19] chore: fix header menu with touch event --- .../common/src/core/slickColumnReorderDrag.ts | 129 ++++++++++++------ 1 file changed, 88 insertions(+), 41 deletions(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index a4a98c9950..408e565dbd 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -17,6 +17,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const { headerLeft, headerRight, container, viewportScrollContainerX, unorderableColumnCssClass } = options; const dragActiveClass = options.dragActiveClass ?? 'slick-header-column-active'; const draggableSelector = options.draggableSelector ?? '.slick-header-column'; + const DRAG_THRESHOLD = 5; // pixels before we consider it a drag, not a click let columnScrollTimer: ReturnType | undefined; let draggedEl: HTMLElement | null = null; @@ -27,6 +28,8 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest let draggedColumnId = ''; let _lastClientX: number | null = null; let dragGhost: HTMLElement | null = null; + let dragStartX: number | null = null; + let dragStartY: number | null = null; const scrollColumnsRight = () => (viewportScrollContainerX.scrollLeft += 10); const scrollColumnsLeft = () => (viewportScrollContainerX.scrollLeft -= 10); @@ -301,10 +304,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } } else { // Pointer fallback (mouse on FF/Linux, touch on all platforms) - e.preventDefault(); - e.stopPropagation(); - createFallbackGhost(target, clientX, clientY); - fallbackActive = true; + // Don't preventDefault yet - wait for drag threshold before committing to drag + dragStartX = clientX; + dragStartY = clientY; if ('touches' in e) { document.addEventListener('touchmove', onPointerMove as EventListener, { passive: false }); document.addEventListener('touchend', onPointerUp as EventListener); @@ -317,35 +319,53 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest }; const onPointerMove = (e: MouseEvent | TouchEvent) => { - if (fallbackActive && draggedEl) { - e.preventDefault(); + if (draggedEl && dragStartX != null && dragStartY != null) { const { clientX, clientY, pageX } = getPointerPos(e); - updateFallbackGhost(clientX, clientY); - // 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, 100); - } else if (!columnScrollTimer && pageX < viewportLeft) { - columnScrollTimer = setInterval(scrollColumnsLeft, 100); - } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { - stopAutoScroll(); + // 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 + e.preventDefault(); + createFallbackGhost(draggedEl, clientX, clientY); + fallbackActive = true; } - const elUnder = document.elementFromPoint(clientX, clientY) as HTMLElement | null; - const overDropzone = isOverDropzone(elUnder); - if (overDropzone) { - dropzoneTargetActive = true; - } else { - dropzoneTargetActive = false; - const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; - if (targetHeader) { - try { - reorderDraggedAgainstTarget(targetHeader, clientX); - } catch (err) { - // ignore DOM insertion errors + // Now handle the actual drag movement + if (fallbackActive) { + e.preventDefault(); + updateFallbackGhost(clientX, clientY); + + // 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, 100); + } else if (!columnScrollTimer && pageX < viewportLeft) { + columnScrollTimer = setInterval(scrollColumnsLeft, 100); + } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { + stopAutoScroll(); + } + + const elUnder = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + const overDropzone = isOverDropzone(elUnder); + if (overDropzone) { + dropzoneTargetActive = true; + } else { + dropzoneTargetActive = false; + const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; + if (targetHeader) { + try { + reorderDraggedAgainstTarget(targetHeader, clientX); + } catch (err) { + // ignore DOM insertion errors + } } } } @@ -391,6 +411,8 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest originalNextSibling = null; draggedColumnId = ''; _lastClientX = null; + dragStartX = null; + dragStartY = null; clearDropzoneTarget(); fallbackActive = false; clearFallbackGhost(); @@ -464,12 +486,15 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro 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|wayland)/i.test(userAgent); let draggedPill: HTMLElement | null = null; let fallbackActive = false; + let dragStartX: number | null = null; + let dragStartY: number | null = null; // ── Native pill drag ────────────────────────────────────────────────────── @@ -553,10 +578,11 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro const onPointerDown = (e: MouseEvent | TouchEvent) => { const pill = (e.target as HTMLElement).closest(itemSelector); if (pill) { - e.preventDefault(); - e.stopPropagation(); + // Track start position for drag threshold, but don't preventDefault yet + dragStartX = getPointerPos(e).clientX; + dragStartY = getPointerPos(e).clientY; draggedPill = pill; - fallbackActive = true; + // Add visual class immediately for UX feedback if (draggingCssClass) pill.classList.add(draggingCssClass); if ('touches' in e) { document.addEventListener('touchmove', onPointerMove as EventListener, { passive: false }); @@ -570,16 +596,33 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro }; const onPointerMove = (e: MouseEvent | TouchEvent) => { - if (fallbackActive && draggedPill) { - e.preventDefault(); + if (draggedPill && dragStartX != null && dragStartY != null) { const { clientX, clientY } = getPointerPos(e); - 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); + + // 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); + } } } } @@ -594,6 +637,8 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro const currentPill = draggedPill; if (currentPill && draggingCssClass) currentPill.classList.remove(draggingCssClass); draggedPill = null; + dragStartX = null; + dragStartY = null; fallbackActive = false; if (currentPill) options.onPillDragEnd?.(currentPill); }; @@ -631,6 +676,8 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro document.removeEventListener('touchend', onPointerUp as EventListener); document.removeEventListener('touchcancel', onPointerUp as EventListener); draggedPill = null; + dragStartX = null; + dragStartY = null; fallbackActive = false; }, }; From e27610f3069b115be7a8187904acbef03c79b34e Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 11:53:04 -0400 Subject: [PATCH 08/19] chore: fix header menu with touch event --- packages/common/src/core/slickColumnReorderDrag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 408e565dbd..37aaa68fa3 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -598,7 +598,7 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro 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); From 0ebff6076511dd0fc0d021aadf4492976aef3d85 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 11:59:12 -0400 Subject: [PATCH 09/19] chore: avoid using dragActiveClass too early with header menu --- packages/common/src/core/slickColumnReorderDrag.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 37aaa68fa3..c23f2ece36 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -275,12 +275,13 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest clearDropzoneTarget(); originalParent = target.parentElement; originalNextSibling = target.nextSibling; - target.classList.add(dragActiveClass); 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'; @@ -332,6 +333,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } // Threshold exceeded - now commit to drag e.preventDefault(); + draggedEl.classList.add(dragActiveClass); createFallbackGhost(draggedEl, clientX, clientY); fallbackActive = true; } @@ -578,11 +580,11 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro const onPointerDown = (e: MouseEvent | TouchEvent) => { const pill = (e.target as HTMLElement).closest(itemSelector); if (pill) { - // Track start position for drag threshold, but don't preventDefault yet + // Track start position for drag threshold dragStartX = getPointerPos(e).clientX; dragStartY = getPointerPos(e).clientY; draggedPill = pill; - // Add visual class immediately for UX feedback + // Add visual feedback immediately for pills (no menu conflict) if (draggingCssClass) pill.classList.add(draggingCssClass); if ('touches' in e) { document.addEventListener('touchmove', onPointerMove as EventListener, { passive: false }); From b2ca91e8ee1acd8d734e1da096fe69d9734f90b6 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 12:06:00 -0400 Subject: [PATCH 10/19] chore: add missing ghost element while using touch to drag to dropzone --- packages/common/src/core/slickColumnReorderDrag.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index c23f2ece36..beb548a76c 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -305,9 +305,12 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } } else { // Pointer fallback (mouse on FF/Linux, touch on all platforms) - // Don't preventDefault yet - wait for drag threshold before committing to drag + // Create ghost immediately for visual feedback, but don't add dragActiveClass yet + createFallbackGhost(target, clientX, clientY); dragStartX = clientX; dragStartY = clientY; + // Disable text selection during drag to prevent selection visual in Firefox/Linux + document.body.style.userSelect = 'none'; if ('touches' in e) { document.addEventListener('touchmove', onPointerMove as EventListener, { passive: false }); document.addEventListener('touchend', onPointerUp as EventListener); @@ -334,7 +337,6 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest // Threshold exceeded - now commit to drag e.preventDefault(); draggedEl.classList.add(dragActiveClass); - createFallbackGhost(draggedEl, clientX, clientY); fallbackActive = true; } @@ -418,6 +420,8 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest clearDropzoneTarget(); fallbackActive = false; clearFallbackGhost(); + // Re-enable text selection after drag completes + document.body.style.userSelect = ''; }; for (const parent of [headerLeft, headerRight]) { @@ -586,6 +590,8 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro 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); @@ -642,6 +648,8 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro dragStartX = null; dragStartY = null; fallbackActive = false; + // Re-enable text selection after drag completes + document.body.style.userSelect = ''; if (currentPill) options.onPillDragEnd?.(currentPill); }; From 1c343d1c716f6e11a0d98d8430511b1f7c9a3934 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 12:11:01 -0400 Subject: [PATCH 11/19] chore: add missing ghost element while using touch --- packages/common/src/core/slickColumnReorderDrag.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index beb548a76c..afe949ec39 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -326,6 +326,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest if (draggedEl && dragStartX != null && dragStartY != null) { const { clientX, clientY, pageX } = getPointerPos(e); + // Always update ghost position for visual feedback + updateFallbackGhost(clientX, clientY); + // Check if we've exceeded the drag threshold if (!fallbackActive) { const deltaX = Math.abs(clientX - dragStartX); @@ -343,7 +346,6 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest // Now handle the actual drag movement if (fallbackActive) { e.preventDefault(); - updateFallbackGhost(clientX, clientY); // browser-edge auto-scroll const containerOffset = getOffset(container); From 961dc93cee459966661cac86c258ba8a2425eceb Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 13:01:32 -0400 Subject: [PATCH 12/19] chore: add missing ghost element while using touch --- .../common/src/core/slickColumnReorderDrag.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index afe949ec39..4558b42e91 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -125,8 +125,16 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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.position = 'fixed'; + dragGhost.style.pointerEvents = 'none'; + dragGhost.style.zIndex = '9999'; + 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); @@ -359,7 +367,13 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest stopAutoScroll(); } - const elUnder = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + 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; From 24664ed55da6ed421b4260d81483d6bb2d06984a Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 13:12:49 -0400 Subject: [PATCH 13/19] chore: fix ghost element not showing with touch --- .../common/src/core/slickColumnReorderDrag.ts | 3 -- packages/common/src/styles/slick-grid.scss | 35 ++++++++++--------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 4558b42e91..5a6ef3d9ec 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -129,9 +129,6 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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.position = 'fixed'; - dragGhost.style.pointerEvents = 'none'; - dragGhost.style.zIndex = '9999'; dragGhost.style.width = `${rect.width}px`; dragGhost.style.height = `${rect.height}px`; dragGhost.style.opacity = '0.8'; diff --git a/packages/common/src/styles/slick-grid.scss b/packages/common/src/styles/slick-grid.scss index 010a787ea1..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 { @@ -100,15 +110,6 @@ background-color: var(--slick-header-column-background-active, v.$slick-header-column-background-active) !important; } - // 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); - } - .slick-headerrow { background: var(--slick-grid-header-background, v.$slick-grid-header-background); } From c5f7403e51940066efb1616d00dd986bb0038439 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 2 Jul 2026 13:18:15 -0400 Subject: [PATCH 14/19] chore: tweak Firefox on Linux detection --- packages/common/src/core/slickColumnReorderDrag.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 5a6ef3d9ec..213108f1f3 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -68,8 +68,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest // Mirror SortableJS's Firefox/Linux fallback detection so the mouse-based path is used only for the broken browser combo. // Include Wayland because Firefox on Linux Wayland may omit the X11 token. - const isFfLinux = - typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) && /(?:linux|wayland)/i.test(navigator.userAgent); + const isFfLinux = typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) && /linux/i.test(navigator.userAgent); const getColumnIds = (parent: HTMLElement): string[] => Array.from(parent.children) @@ -508,7 +507,7 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro 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|wayland)/i.test(userAgent); + const isFfLinux = /firefox/i.test(userAgent) && /linux/i.test(userAgent); let draggedPill: HTMLElement | null = null; let fallbackActive = false; From 48ebfd0bfe7350c7c40b3f189367511d9aea5fd4 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 7 Jul 2026 11:29:07 -0400 Subject: [PATCH 15/19] chore: fix some more issues with touch & header menu, also firefox linux --- .../__tests__/slickColumnReorderDrag.spec.ts | 372 +++++++++++++++++- .../common/src/core/slickColumnReorderDrag.ts | 179 +++++---- .../__tests__/slickDraggableGrouping.spec.ts | 37 +- .../src/extensions/slickDraggableGrouping.ts | 55 ++- .../slickColumnReorderDrag.interfaces.ts | 10 + 5 files changed, 535 insertions(+), 118 deletions(-) diff --git a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts index 692ad49ad7..02b3091fb9 100644 --- a/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts +++ b/packages/common/src/core/__tests__/slickColumnReorderDrag.spec.ts @@ -91,6 +91,113 @@ describe('slickColumnReorderDrag', () => { 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'); @@ -579,6 +686,96 @@ describe('slickColumnReorderDrag', () => { expect(onDragEnd).not.toHaveBeenCalled(); }); + 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'); @@ -696,15 +893,7 @@ describe('slickColumnReorderDrag', () => { document.dispatchEvent(dragEnterEvt); expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName']); - // mouseover path should also restore parent if moved again - foreignParent.append(firstName); - const mouseOverEvt = createMouseEvent('mouseover', { target: dropzone }); - document.dispatchEvent(mouseOverEvt); - expect(Array.from(headerLeft.children).map((el) => (el as HTMLElement).dataset.id)).toEqual(['firstName', 'lastName']); - - // mouseout + dragleave should clear dropzone active state and avoid onDrop - const mouseOutEvt = createMouseEvent('mouseout', { target: dropzone, relatedTarget: document.body }); - document.dispatchEvent(mouseOutEvt); + // dragleave should clear dropzone active state and avoid onDrop const dragLeaveEvt = createDragEvent('dragleave', { target: dropzone }); document.dispatchEvent(dragLeaveEvt); @@ -712,7 +901,7 @@ describe('slickColumnReorderDrag', () => { expect(onDrop).not.toHaveBeenCalled(); }); - it('should keep dropzone active on mouseout when relatedTarget is null but pointer is still over dropzone', () => { + 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'); @@ -754,8 +943,9 @@ describe('slickColumnReorderDrag', () => { // mark dropzone active first document.dispatchEvent(createDragEvent('dragenter', { target: dropzone })); - const mouseOutEvt = createMouseEvent('mouseout', { target: dropzone, relatedTarget: null, clientX: 25, clientY: 25 }); - document.dispatchEvent(mouseOutEvt); + // 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 })); @@ -839,6 +1029,53 @@ describe('slickColumnReorderDrag', () => { 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', { @@ -858,10 +1095,7 @@ describe('slickColumnReorderDrag', () => { dropzone.className = 'slick-dropzone'; document.body.append(nonDropzoneElm, dropzone); - const elementFromPointMock = vi - .fn() - .mockReturnValueOnce(nonDropzoneElm as Element) // mousemove -> not dropzone, keeps internal flag false - .mockReturnValueOnce(dropzone as Element); // mouseup -> should set droppedOnDropzone=true + const elementFromPointMock = vi.fn((x: number) => (x === 30 ? (dropzone as Element) : (nonDropzoneElm as Element))); Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: elementFromPointMock, @@ -881,7 +1115,7 @@ describe('slickColumnReorderDrag', () => { 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 })); + document.dispatchEvent(createMouseEvent('mouseup', { clientX: 30, clientY: 20 })); expect(onDrop).toHaveBeenCalledTimes(1); expect(onDragEnd).not.toHaveBeenCalled(); @@ -889,6 +1123,52 @@ describe('slickColumnReorderDrag', () => { 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', { @@ -957,11 +1237,17 @@ describe('slickColumnReorderDrag', () => { 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: vi.fn(() => { - throw new Error('boom'); - }), + value: elementFromPointMock, }); const onDrop = vi.fn(); @@ -977,10 +1263,12 @@ describe('slickColumnReorderDrag', () => { }); 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 }); }); @@ -1060,6 +1348,50 @@ describe('slickColumnReorderDrag', () => { 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); diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 213108f1f3..330b1d1a54 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -17,11 +17,12 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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 let columnScrollTimer: ReturnType | undefined; let draggedEl: HTMLElement | null = null; - let fallbackActive = false; let originalParent: Node | null = null; let originalNextSibling: ChildNode | null = null; let dropzoneTargetActive = false; @@ -30,6 +31,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest let dragGhost: HTMLElement | null = null; let dragStartX: number | null = null; let dragStartY: number | null = null; + let pointerDragCommitted = false; const scrollColumnsRight = () => (viewportScrollContainerX.scrollLeft += 10); const scrollColumnsLeft = () => (viewportScrollContainerX.scrollLeft -= 10); @@ -44,7 +46,27 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } }; - const isOverDropzone = (el: HTMLElement | null | undefined): boolean => !!el?.closest?.('.slick-dropzone'); + const isOverDropzone = (el: HTMLElement | null | undefined): boolean => !!el?.closest?.(dropzoneSelector); + + const toggleDropzoneHoverClass = (el: HTMLElement | null | undefined, isActive: boolean) => { + const dropzone = el?.closest?.(dropzoneSelector) as HTMLElement | null; + if (dropzone) { + dropzone.classList.toggle(dropzoneHoverClass, isActive); + } + }; + + const clearDropzoneHoverClasses = () => { + document.querySelectorAll(dropzoneSelector).forEach((el) => el.classList.remove(dropzoneHoverClass)); + }; + + 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)) { @@ -140,6 +162,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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) try { restoreDraggedToOriginalParent(); @@ -149,45 +172,26 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } }; - const onDropzoneDragLeave = (e: Event) => { - const target = e.target as HTMLElement | null; - if (isOverDropzone(target)) { - dropzoneTargetActive = false; - } - }; - - const onDropzoneMouseOver = (e: MouseEvent) => { - const target = e.target as HTMLElement | null; - if (draggedEl && isOverDropzone(target)) { - dropzoneTargetActive = true; - try { - restoreDraggedToOriginalParent(); - } catch (err) { - // ignore - } - } - }; - - const onDropzoneMouseOut = (e: MouseEvent) => { + 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); - document.addEventListener('mouseover', onDropzoneMouseOver as EventListener); - document.addEventListener('mouseout', onDropzoneMouseOut 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 try { restoreDraggedToOriginalParent(); @@ -197,6 +201,8 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest return; } + clearDropzoneHoverClasses(); + const target = (e.target as HTMLElement).closest(draggableSelector); if (target) { reorderDraggedAgainstTarget(target, e.clientX); @@ -219,7 +225,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest try { 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('.slick-dropzone')) { + if (el && el.closest && el.closest(dropzoneSelector)) { droppedOnDropzone = true; } } @@ -245,8 +251,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest originalNextSibling = null; draggedColumnId = ''; clearDropzoneTarget(); - fallbackActive = false; + clearDropzoneHoverClasses(); _lastClientX = null; + pointerDragCommitted = false; }; // Firefox/Linux native HTML5 drag is broken; touch screens never fire HTML5 drag events. @@ -265,7 +272,14 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const onStart = (e: DragEvent | MouseEvent | TouchEvent) => { const { clientX, clientY } = getPointerPos(e); - const target = (e.target as HTMLElement).closest(draggableSelector); + const eventTarget = e.target as HTMLElement | null; + if (isDragStartIgnoredTarget(eventTarget, e)) { + if (e.type === 'dragstart') { + e.preventDefault(); + } + return; + } + const target = eventTarget?.closest(draggableSelector); if (!target || !isDraggable(target)) { // Cancel a native drag that started on a non-orderable column if (e.type === 'dragstart') { @@ -279,7 +293,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest clearDropzoneTarget(); originalParent = target.parentElement; originalNextSibling = target.nextSibling; - options.onDragStart?.(target); + if (e.type === 'dragstart') { + options.onDragStart?.(target); + } _lastClientX = clientX; if (e.type === 'dragstart') { @@ -309,12 +325,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } } else { // Pointer fallback (mouse on FF/Linux, touch on all platforms) - // Create ghost immediately for visual feedback, but don't add dragActiveClass yet - createFallbackGhost(target, clientX, clientY); dragStartX = clientX; dragStartY = clientY; - // Disable text selection during drag to prevent selection visual in Firefox/Linux - document.body.style.userSelect = 'none'; + pointerDragCommitted = false; if ('touches' in e) { document.addEventListener('touchmove', onPointerMove as EventListener, { passive: false }); document.addEventListener('touchend', onPointerUp as EventListener); @@ -330,11 +343,8 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest if (draggedEl && dragStartX != null && dragStartY != null) { const { clientX, clientY, pageX } = getPointerPos(e); - // Always update ghost position for visual feedback - updateFallbackGhost(clientX, clientY); - // Check if we've exceeded the drag threshold - if (!fallbackActive) { + if (!pointerDragCommitted) { const deltaX = Math.abs(clientX - dragStartX); const deltaY = Math.abs(clientY - dragStartY); if (deltaX < DRAG_THRESHOLD && deltaY < DRAG_THRESHOLD) { @@ -343,45 +353,55 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } // Threshold exceeded - now commit to drag e.preventDefault(); + options.onDragStart?.(draggedEl); + createFallbackGhost(draggedEl, clientX, clientY); draggedEl.classList.add(dragActiveClass); - fallbackActive = true; + // Disable text selection only after drag intent is confirmed + document.body.style.userSelect = 'none'; + pointerDragCommitted = true; } - // Now handle the actual drag movement - if (fallbackActive) { - e.preventDefault(); + // Always update ghost position for visual feedback once drag is committed + updateFallbackGhost(clientX, clientY); - // 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, 100); - } else if (!columnScrollTimer && pageX < viewportLeft) { - columnScrollTimer = setInterval(scrollColumnsLeft, 100); - } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { - stopAutoScroll(); - } + e.preventDefault(); - 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; - } else { - dropzoneTargetActive = false; - const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; - if (targetHeader) { - try { - reorderDraggedAgainstTarget(targetHeader, clientX); - } catch (err) { - // ignore DOM insertion errors - } + // 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, 100); + } else if (!columnScrollTimer && pageX < viewportLeft) { + columnScrollTimer = setInterval(scrollColumnsLeft, 100); + } 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) { + try { + reorderDraggedAgainstTarget(targetHeader, clientX); + } catch (err) { + // ignore DOM insertion errors } } } @@ -395,16 +415,17 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest document.removeEventListener('touchend', onPointerUp as EventListener); document.removeEventListener('touchcancel', onPointerUp as EventListener); const draggedHeader = draggedEl; - if (draggedHeader) draggedHeader.classList.remove(dragActiveClass); + if (draggedHeader) { + draggedHeader.classList.remove(dragActiveClass); + } stopAutoScroll(); - fallbackActive = false; const { clientX, clientY } = getPointerPos(e); let droppedOnDropzone = dropzoneTargetActive; try { if (!droppedOnDropzone) { const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null; - if (el?.closest?.('.slick-dropzone')) droppedOnDropzone = true; + if (el?.closest?.(dropzoneSelector)) droppedOnDropzone = true; } } catch (err) { droppedOnDropzone = false; @@ -417,9 +438,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } } const reorderedIds = getColumnIds(headerLeft).concat(getColumnIds(headerRight)); - if (droppedOnDropzone && draggedHeader) { + if (pointerDragCommitted && droppedOnDropzone && draggedHeader) { options.onDrop?.(draggedHeader, e, draggedColumnId); - } else { + } else if (pointerDragCommitted) { options.onDragEnd(reorderedIds); } draggedEl = null; @@ -429,8 +450,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest _lastClientX = null; dragStartX = null; dragStartY = null; + pointerDragCommitted = false; clearDropzoneTarget(); - fallbackActive = false; + clearDropzoneHoverClasses(); clearFallbackGhost(); // Re-enable text selection after drag completes document.body.style.userSelect = ''; @@ -467,15 +489,14 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest document.removeEventListener('touchcancel', onPointerUp as EventListener); document.removeEventListener('dragenter', onDropzoneDragEnter as EventListener); document.removeEventListener('dragleave', onDropzoneDragLeave as EventListener); - document.removeEventListener('mouseover', onDropzoneMouseOver as EventListener); - document.removeEventListener('mouseout', onDropzoneMouseOut as EventListener); stopAutoScroll(); draggedEl = null; originalParent = null; originalNextSibling = null; draggedColumnId = ''; - fallbackActive = false; + pointerDragCommitted = false; clearDropzoneTarget(); + clearDropzoneHoverClasses(); clearFallbackGhost(); [headerLeft, headerRight].forEach((parent) => Array.from(parent.querySelectorAll(draggableSelector)).forEach((el) => { diff --git a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts index 1322344a55..990a6a8f64 100644 --- a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts +++ b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts @@ -427,7 +427,11 @@ describe('Draggable Grouping Plugin', () => { 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(); @@ -436,6 +440,37 @@ 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); diff --git a/packages/common/src/extensions/slickDraggableGrouping.ts b/packages/common/src/extensions/slickDraggableGrouping.ts index 95214c1a09..54a8b28a81 100644 --- a/packages/common/src/extensions/slickDraggableGrouping.ts +++ b/packages/common/src/extensions/slickDraggableGrouping.ts @@ -17,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 @@ -128,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); @@ -136,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( @@ -173,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, @@ -288,8 +294,10 @@ export class SlickDraggableGrouping { ): 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 dropzoneSelector = `.${DROPZONE_CLASS}`; + const dropzoneHoverClass = DROPZONE_HOVER_CLASS; + const draggablePlaceholderElm = dropzoneElm.querySelector(`.${DROPZONE_PLACEHOLDER_CLASS}`); + const groupTogglerElm = dropzoneElm.querySelector(`.${GROUP_TOGGLE_ALL_CLASS}`); const uid = grid.getUID(); this._columnReorderDrag = setupColumnReorderDrag({ @@ -299,6 +307,8 @@ export class SlickDraggableGrouping { viewportScrollContainerX: (grid as any).getViewportNode?.() ?? this.gridContainer, hasFrozenColumns: () => ((grid.getOptions?.() as GridOption | undefined)?.frozenColumn ?? -1) >= 0, draggableSelector: '.slick-header-column', + dropzoneSelector, + dropzoneHoverClass, onDragStart: () => { if (draggablePlaceholderElm) { draggablePlaceholderElm.style.display = 'inline-block'; @@ -309,7 +319,7 @@ export class SlickDraggableGrouping { } }, onDrop: (draggedEl, _event, draggedColumnId) => { - dropzoneElm?.classList.remove('slick-dropzone-hover'); + dropzoneElm?.classList.remove(dropzoneHoverClass); draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); droppedGroupingElms.forEach((el) => (el.style.display = 'flex')); @@ -328,7 +338,7 @@ export class SlickDraggableGrouping { } }, onDragEnd: (reorderedIds) => { - dropzoneElm?.classList.remove('slick-dropzone-hover'); + dropzoneElm?.classList.remove(dropzoneHoverClass); draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); droppedGroupingElms.forEach((el) => (el.style.display = 'flex')); @@ -506,7 +516,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); @@ -538,14 +548,17 @@ 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)); } } @@ -567,17 +580,21 @@ export class SlickDraggableGrouping { this.columnsGroupBy = newGroupingOrder; this.updateGroupBy('sort-group'); }, - onColumnDragEnter: () => dropzoneElm.classList.add('slick-dropzone-hover'), + 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('slick-dropzone-hover'); + dropzoneElm.classList.remove(DROPZONE_HOVER_CLASS); } }, onColumnDrop: (columnDataId) => { - dropzoneElm.classList.remove('slick-dropzone-hover'); + dropzoneElm.classList.remove(DROPZONE_HOVER_CLASS); const headerColumnElm = this.gridContainer.querySelector(`[data-id="${columnDataId}"]`); if (headerColumnElm) { this.handleGroupByDrop(dropzoneElm, headerColumnElm); @@ -591,7 +608,9 @@ export class SlickDraggableGrouping { 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/slickColumnReorderDrag.interfaces.ts b/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts index 39907bb843..3cd17c1b02 100644 --- a/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts +++ b/packages/common/src/interfaces/slickColumnReorderDrag.interfaces.ts @@ -15,6 +15,16 @@ export interface ColumnReorderDragOption { 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. From 2200e6f11882b30edfc1e58498892137de533a57 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 7 Jul 2026 11:48:30 -0400 Subject: [PATCH 16/19] chore: add missing test coverage --- .../__tests__/slickDraggableGrouping.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts index 990a6a8f64..840e1cb407 100644 --- a/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts +++ b/packages/common/src/extensions/__tests__/slickDraggableGrouping.spec.ts @@ -800,6 +800,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'); From 24d5fa054a779683f2c14788397c30cdb2ccbd63 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 7 Jul 2026 11:52:14 -0400 Subject: [PATCH 17/19] chore: simplify Draggable Grouping & add curly brace oxlint rule --- .oxlintrc.json | 3 +- .../src/examples/slickgrid/example27.ts | 8 +- .../slickgrid/example45-detail-view.ts | 4 +- demos/aurelia/src/examples/slickgrid/jsonp.ts | 8 +- .../src/examples/slickgrid/Example27.tsx | 8 +- demos/react/src/examples/slickgrid/jsonp.ts | 8 +- demos/vanilla/src/examples/example05.ts | 8 +- demos/vanilla/src/examples/example30-pager.ts | 32 +++++-- demos/vanilla/src/examples/jsonp.ts | 8 +- demos/vue/src/components/Example27.vue | 8 +- demos/vue/src/components/jsonp.ts | 8 +- .../src/demos/examples/example18.component.ts | 4 +- .../src/demos/examples/example27.component.ts | 8 +- .../src/demos/examples/jsonp.ts | 8 +- .../src/core/__tests__/slickGrid.spec.ts | 4 +- .../common/src/core/slickColumnReorderDrag.ts | 88 +++++++++++-------- packages/common/src/core/slickGrid.ts | 8 +- .../src/extensions/slickDraggableGrouping.ts | 51 +++++------ .../__tests__/filterUtilities.spec.ts | 12 ++- packages/pdf-export/src/pdfExport.service.ts | 4 +- packages/sql/src/services/sql.service.ts | 4 +- packages/utils/src/domUtils.ts | 12 ++- 22 files changed, 199 insertions(+), 107 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 798a617ba9..cbf4a262d6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -76,6 +76,7 @@ "import/no-self-import": "error", "no-async-promise-executor": "off", "oxc/erasing-op": "off", - "object-shorthand": "error" + "object-shorthand": "error", + "curly": ["error", "all"] } } diff --git a/demos/aurelia/src/examples/slickgrid/example27.ts b/demos/aurelia/src/examples/slickgrid/example27.ts index 61316b37fd..2cf40a53d8 100644 --- a/demos/aurelia/src/examples/slickgrid/example27.ts +++ b/demos/aurelia/src/examples/slickgrid/example27.ts @@ -385,7 +385,9 @@ export class Example27 { setMaxVisibleDepthFromInput() { const input = document.querySelector('#maxVisibleDepthInput') as HTMLInputElement; - if (!input) return; + if (!input) { + return; + } const value = parseInt(input.value, 10); const maxVisibleDepth = Number.isFinite(value) ? value : undefined; this.aureliaGrid.treeDataService.setMaxVisibleDepth(maxVisibleDepth as number | undefined); @@ -393,7 +395,9 @@ export class Example27 { clearMaxVisibleDepth() { const input = document.querySelector('#maxVisibleDepthInput') as HTMLInputElement; - if (input) input.value = ''; + if (input) { + input.value = ''; + } this.aureliaGrid.treeDataService.clearMaxVisibleDepth(); } } diff --git a/demos/aurelia/src/examples/slickgrid/example45-detail-view.ts b/demos/aurelia/src/examples/slickgrid/example45-detail-view.ts index 6fc6836f67..84ae04ae7f 100644 --- a/demos/aurelia/src/examples/slickgrid/example45-detail-view.ts +++ b/demos/aurelia/src/examples/slickgrid/example45-detail-view.ts @@ -32,7 +32,9 @@ export class Example45DetailView { innerGridClass = ''; attached() { - if (!this.model) return; + if (!this.model) { + return; + } this.gridId = `innergrid-${this.model.id}`; this.innerGridClass = `row-detail-${this.model.id}`; diff --git a/demos/aurelia/src/examples/slickgrid/jsonp.ts b/demos/aurelia/src/examples/slickgrid/jsonp.ts index 946950a9aa..db284d626f 100644 --- a/demos/aurelia/src/examples/slickgrid/jsonp.ts +++ b/demos/aurelia/src/examples/slickgrid/jsonp.ts @@ -41,7 +41,9 @@ function fetchJsonp(_url: string, options: Partial = {}): (window as any)[callbackFunction] = (response: T) => { // keep consistent with fetch API resolve({ ok: true, json: () => Promise.resolve(response) }); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } removeScript(scriptId); clearFunction(callbackFunction); }; @@ -81,7 +83,9 @@ function fetchJsonp(_url: string, options: Partial = {}): reject(new Error(`JSONP request to ${_url} failed`)); clearFunction(callbackFunction); removeScript(scriptId); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } }; }); } diff --git a/demos/react/src/examples/slickgrid/Example27.tsx b/demos/react/src/examples/slickgrid/Example27.tsx index dad1e2a438..6430b697f3 100644 --- a/demos/react/src/examples/slickgrid/Example27.tsx +++ b/demos/react/src/examples/slickgrid/Example27.tsx @@ -352,7 +352,9 @@ const Example27: React.FC = () => { function setMaxVisibleDepthFromInput() { const input = document.querySelector('#maxVisibleDepthInput') as HTMLInputElement; - if (!input) return; + if (!input) { + return; + } const value = parseInt(input.value, 10); const maxVisibleDepth = Number.isFinite(value) ? value : undefined; reactGridRef.current?.treeDataService.setMaxVisibleDepth(maxVisibleDepth as number | undefined); @@ -360,7 +362,9 @@ const Example27: React.FC = () => { function clearMaxVisibleDepth() { const input = document.querySelector('#maxVisibleDepthInput') as HTMLInputElement; - if (input) input.value = ''; + if (input) { + input.value = ''; + } reactGridRef.current?.treeDataService.clearMaxVisibleDepth(); } diff --git a/demos/react/src/examples/slickgrid/jsonp.ts b/demos/react/src/examples/slickgrid/jsonp.ts index 946950a9aa..db284d626f 100644 --- a/demos/react/src/examples/slickgrid/jsonp.ts +++ b/demos/react/src/examples/slickgrid/jsonp.ts @@ -41,7 +41,9 @@ function fetchJsonp(_url: string, options: Partial = {}): (window as any)[callbackFunction] = (response: T) => { // keep consistent with fetch API resolve({ ok: true, json: () => Promise.resolve(response) }); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } removeScript(scriptId); clearFunction(callbackFunction); }; @@ -81,7 +83,9 @@ function fetchJsonp(_url: string, options: Partial = {}): reject(new Error(`JSONP request to ${_url} failed`)); clearFunction(callbackFunction); removeScript(scriptId); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } }; }); } diff --git a/demos/vanilla/src/examples/example05.ts b/demos/vanilla/src/examples/example05.ts index 964e218988..3c6874cef6 100644 --- a/demos/vanilla/src/examples/example05.ts +++ b/demos/vanilla/src/examples/example05.ts @@ -403,7 +403,9 @@ export default class Example05 { setMaxVisibleDepthFromInput() { const input = document.querySelector('#maxVisibleDepthInput') as HTMLInputElement; - if (!input) return; + if (!input) { + return; + } const value = parseInt(input.value, 10); const maxVisibleDepth = Number.isFinite(value) ? value : undefined; this.sgb.treeDataService.setMaxVisibleDepth(maxVisibleDepth as number | undefined); @@ -411,7 +413,9 @@ export default class Example05 { clearMaxVisibleDepth() { const input = document.querySelector('#maxVisibleDepthInput') as HTMLInputElement; - if (input) input.value = ''; + if (input) { + input.value = ''; + } this.sgb.treeDataService.clearMaxVisibleDepth(); } diff --git a/demos/vanilla/src/examples/example30-pager.ts b/demos/vanilla/src/examples/example30-pager.ts index 0b085dfb5a..1e8ace09f8 100644 --- a/demos/vanilla/src/examples/example30-pager.ts +++ b/demos/vanilla/src/examples/example30-pager.ts @@ -161,30 +161,34 @@ export class CustomPager implements BasePaginationComponent { const nextBtn = this._paginationElement.querySelector('li.page-item.seek-next') as HTMLLIElement; // VueJS-style class binding for buttons - if (firstBtn) + if (firstBtn) { bindClassList(firstBtn, () => ({ 'page-item': true, 'seek-first': true, disabled: this.isLeftPaginationDisabled(), })); - if (prevBtn) + } + if (prevBtn) { bindClassList(prevBtn, () => ({ 'page-item': true, 'seek-prev': true, disabled: this.isLeftPaginationDisabled(), })); - if (lastBtn) + } + if (lastBtn) { bindClassList(lastBtn, () => ({ 'page-item': true, 'seek-end': true, disabled: this.isRightPaginationDisabled(), })); - if (nextBtn) + } + if (nextBtn) { bindClassList(nextBtn, () => ({ 'page-item': true, 'seek-next': true, disabled: this.isRightPaginationDisabled(), })); + } // Text content for spans const itemFrom = this._paginationElement.querySelector('span.item-from'); @@ -194,19 +198,29 @@ export class CustomPager implements BasePaginationComponent { const pageNumber = this._paginationElement.querySelector('span.page-number'); effect(() => { - if (itemFrom) itemFrom.textContent = String(this.dataFrom()); + if (itemFrom) { + itemFrom.textContent = String(this.dataFrom()); + } }); effect(() => { - if (itemTo) itemTo.textContent = String(this.dataTo()); + if (itemTo) { + itemTo.textContent = String(this.dataTo()); + } }); effect(() => { - if (totalItems) totalItems.textContent = String(this.totalItems()); + if (totalItems) { + totalItems.textContent = String(this.totalItems()); + } }); effect(() => { - if (pageCount) pageCount.textContent = String(this.pageCount()); + if (pageCount) { + pageCount.textContent = String(this.pageCount()); + } }); effect(() => { - if (pageNumber) pageNumber.textContent = String(this.pageNumber()); + if (pageNumber) { + pageNumber.textContent = String(this.pageNumber()); + } }); } diff --git a/demos/vanilla/src/examples/jsonp.ts b/demos/vanilla/src/examples/jsonp.ts index 946950a9aa..db284d626f 100644 --- a/demos/vanilla/src/examples/jsonp.ts +++ b/demos/vanilla/src/examples/jsonp.ts @@ -41,7 +41,9 @@ function fetchJsonp(_url: string, options: Partial = {}): (window as any)[callbackFunction] = (response: T) => { // keep consistent with fetch API resolve({ ok: true, json: () => Promise.resolve(response) }); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } removeScript(scriptId); clearFunction(callbackFunction); }; @@ -81,7 +83,9 @@ function fetchJsonp(_url: string, options: Partial = {}): reject(new Error(`JSONP request to ${_url} failed`)); clearFunction(callbackFunction); removeScript(scriptId); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } }; }); } diff --git a/demos/vue/src/components/Example27.vue b/demos/vue/src/components/Example27.vue index 0261f73a66..6c993b337e 100644 --- a/demos/vue/src/components/Example27.vue +++ b/demos/vue/src/components/Example27.vue @@ -378,7 +378,9 @@ function reapplyToggledItems() { function setMaxVisibleDepthFromInput() { const input = document.querySelector('#maxVisibleDepthInput') as HTMLInputElement; - if (!input) return; + if (!input) { + return; + } const value = parseInt(input.value, 10); const maxVisibleDepth = Number.isFinite(value) ? value : undefined; vueGrid.treeDataService.setMaxVisibleDepth(maxVisibleDepth as number | undefined); @@ -386,7 +388,9 @@ function setMaxVisibleDepthFromInput() { function clearMaxVisibleDepth() { const input = document.querySelector('#maxVisibleDepthInput') as HTMLInputElement; - if (input) input.value = ''; + if (input) { + input.value = ''; + } vueGrid.treeDataService.clearMaxVisibleDepth(); } diff --git a/demos/vue/src/components/jsonp.ts b/demos/vue/src/components/jsonp.ts index 946950a9aa..db284d626f 100644 --- a/demos/vue/src/components/jsonp.ts +++ b/demos/vue/src/components/jsonp.ts @@ -41,7 +41,9 @@ function fetchJsonp(_url: string, options: Partial = {}): (window as any)[callbackFunction] = (response: T) => { // keep consistent with fetch API resolve({ ok: true, json: () => Promise.resolve(response) }); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } removeScript(scriptId); clearFunction(callbackFunction); }; @@ -81,7 +83,9 @@ function fetchJsonp(_url: string, options: Partial = {}): reject(new Error(`JSONP request to ${_url} failed`)); clearFunction(callbackFunction); removeScript(scriptId); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } }; }); } diff --git a/frameworks/angular-slickgrid/src/demos/examples/example18.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example18.component.ts index dae9b619a8..b0a7bb2bfe 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example18.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example18.component.ts @@ -410,7 +410,9 @@ export class Example18Component implements OnInit, OnDestroy { if (Array.isArray(groups) && groups.length > 0) { // assign a new array reference to trigger Angular change detection const newFields = groups.map((g) => g?.getter ?? ''); - while (newFields.length < 3) newFields.push(''); + while (newFields.length < 3) { + newFields.push(''); + } this.selectedGroupingFields.set(newFields); } else if (groups.length === 0 && caller === 'remove-group') { this.clearGroupingSelects(); diff --git a/frameworks/angular-slickgrid/src/demos/examples/example27.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example27.component.ts index efe6d45acd..9c840af757 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example27.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example27.component.ts @@ -384,7 +384,9 @@ export class Example27Component implements OnInit { setMaxVisibleDepthFromInput() { const input = document.getElementById('maxVisibleDepthInput') as HTMLInputElement; - if (!input) return; + if (!input) { + return; + } const value = parseInt(input.value, 10); const maxVisibleDepth = Number.isFinite(value) ? value : undefined; this.angularGrid.treeDataService.setMaxVisibleDepth(maxVisibleDepth as number | undefined); @@ -392,7 +394,9 @@ export class Example27Component implements OnInit { clearMaxVisibleDepth() { const input = document.getElementById('maxVisibleDepthInput') as HTMLInputElement; - if (input) input.value = ''; + if (input) { + input.value = ''; + } this.angularGrid.treeDataService.clearMaxVisibleDepth(); } } diff --git a/frameworks/angular-slickgrid/src/demos/examples/jsonp.ts b/frameworks/angular-slickgrid/src/demos/examples/jsonp.ts index 946950a9aa..db284d626f 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/jsonp.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/jsonp.ts @@ -41,7 +41,9 @@ function fetchJsonp(_url: string, options: Partial = {}): (window as any)[callbackFunction] = (response: T) => { // keep consistent with fetch API resolve({ ok: true, json: () => Promise.resolve(response) }); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } removeScript(scriptId); clearFunction(callbackFunction); }; @@ -81,7 +83,9 @@ function fetchJsonp(_url: string, options: Partial = {}): reject(new Error(`JSONP request to ${_url} failed`)); clearFunction(callbackFunction); removeScript(scriptId); - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } }; }); } diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 5076d9c574..7c0c974f76 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -6871,7 +6871,9 @@ describe('SlickGrid core file', () => { expect(onHeaderKeyDownSpy).toHaveBeenCalled(); expect(onSortSpy).toHaveBeenCalled(); // Restore original - if (origStorage) (globalThis as any).Utils.storage.get = origStorage; + if (origStorage) { + (globalThis as any).Utils.storage.get = origStorage; + } }); it('should return cell CSS styles for a given key', () => { diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index 330b1d1a54..cd3966f417 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -59,6 +59,14 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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' @@ -164,11 +172,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest dropzoneTargetActive = true; toggleDropzoneHoverClass(target, true); // Ensure the dragged header remains in the header DOM (non-destructive) - try { + safely(() => { restoreDraggedToOriginalParent(); - } catch (err) { - // ignore - } + }); } }; @@ -193,11 +199,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest dropzoneTargetActive = true; toggleDropzoneHoverClass(e.target as HTMLElement | null, true); // Keep the dragged header visible in the original header DOM while over the dropzone - try { + safely(() => { restoreDraggedToOriginalParent(); - } catch (err) { - // ignore - } + }); return; } @@ -222,16 +226,19 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest // 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; - try { - 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; + 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; } - } catch (err) { - droppedOnDropzone = false; - } + ); if (droppedOnDropzone && draggedHeader && originalParent) { // restore the header DOM to its original location @@ -398,11 +405,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest clearDropzoneHoverClasses(); const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; if (targetHeader) { - try { + safely(() => { reorderDraggedAgainstTarget(targetHeader, clientX); - } catch (err) { - // ignore DOM insertion errors - } + }); } } } @@ -422,20 +427,25 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const { clientX, clientY } = getPointerPos(e); let droppedOnDropzone = dropzoneTargetActive; - try { - if (!droppedOnDropzone) { - const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null; - if (el?.closest?.(dropzoneSelector)) droppedOnDropzone = true; - } - } catch (err) { - droppedOnDropzone = false; - } - if (droppedOnDropzone && draggedHeader && originalParent) { - try { - originalParent.insertBefore(draggedHeader, originalNextSibling as Node | null); - } catch (err) { - // ignore + 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) { @@ -622,7 +632,9 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro dragStartY = getPointerPos(e).clientY; draggedPill = pill; // Add visual feedback immediately for pills (no menu conflict) - if (draggingCssClass) pill.classList.add(draggingCssClass); + if (draggingCssClass) { + pill.classList.add(draggingCssClass); + } // Disable text selection during drag document.body.style.userSelect = 'none'; if ('touches' in e) { @@ -676,14 +688,18 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro document.removeEventListener('touchend', onPointerUp as EventListener); document.removeEventListener('touchcancel', onPointerUp as EventListener); const currentPill = draggedPill; - if (currentPill && draggingCssClass) currentPill.classList.remove(draggingCssClass); + 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); + if (currentPill) { + options.onPillDragEnd?.(currentPill); + } }; // ── Register listeners ──────────────────────────────────────────────────── diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 2040672d8d..78e0a6adf8 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -2248,7 +2248,9 @@ export class SlickGrid = Column, O e }; const scheduleColumnResizeAutoScroll = (resizeCallback: (targetPageX: number) => void) => { - if (this._columnResizeAutoScrollTimer || !autoScrollOffsetX) return; + if (this._columnResizeAutoScrollTimer || !autoScrollOffsetX) { + return; + } let elapsed = 0; this._columnResizeAutoScrollTimer = setInterval(() => { if (!autoScrollOffsetX) { @@ -2262,7 +2264,9 @@ export class SlickGrid = Column, O e if (!isBrowserEdge) { const delay = RESIZE_AUTOSCROLL_MAX_INTERVAL_MS - Math.abs(autoScrollOffsetX) * RESIZE_AUTOSCROLL_ACCELERATE_INTERVAL; elapsed += RESIZE_AUTOSCROLL_MIN_INTERVAL_MS; - if (elapsed < delay) return; + if (elapsed < delay) { + return; + } elapsed = 0; } /* v8 ignore next */ diff --git a/packages/common/src/extensions/slickDraggableGrouping.ts b/packages/common/src/extensions/slickDraggableGrouping.ts index 54a8b28a81..bed7a7c0e4 100644 --- a/packages/common/src/extensions/slickDraggableGrouping.ts +++ b/packages/common/src/extensions/slickDraggableGrouping.ts @@ -294,12 +294,25 @@ export class SlickDraggableGrouping { ): void { this.destroyColumnReorderDrag(); const dropzoneElm = grid.getTopHeaderPanel() || grid.getPreHeaderPanel(); - const dropzoneSelector = `.${DROPZONE_CLASS}`; - const dropzoneHoverClass = DROPZONE_HOVER_CLASS; 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`)!, @@ -307,8 +320,8 @@ export class SlickDraggableGrouping { viewportScrollContainerX: (grid as any).getViewportNode?.() ?? this.gridContainer, hasFrozenColumns: () => ((grid.getOptions?.() as GridOption | undefined)?.frozenColumn ?? -1) >= 0, draggableSelector: '.slick-header-column', - dropzoneSelector, - dropzoneHoverClass, + dropzoneSelector: `.${DROPZONE_CLASS}`, + dropzoneHoverClass: DROPZONE_HOVER_CLASS, onDragStart: () => { if (draggablePlaceholderElm) { draggablePlaceholderElm.style.display = 'inline-block'; @@ -319,18 +332,7 @@ export class SlickDraggableGrouping { } }, onDrop: (draggedEl, _event, draggedColumnId) => { - dropzoneElm?.classList.remove(dropzoneHoverClass); - draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); - const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); - droppedGroupingElms.forEach((el) => (el.style.display = 'flex')); - if (droppedGroupingElms.length) { - if (draggablePlaceholderElm) { - draggablePlaceholderElm.style.display = 'none'; - } - if (groupTogglerElm) { - groupTogglerElm.style.display = 'inline-flex'; - } - } + restoreDropzoneState(); const headerColumnElm = (draggedEl as HTMLDivElement | null) ?? this.gridContainer.querySelector(`[data-id="${draggedColumnId}"]`); if (headerColumnElm) { @@ -338,18 +340,7 @@ export class SlickDraggableGrouping { } }, onDragEnd: (reorderedIds) => { - dropzoneElm?.classList.remove(dropzoneHoverClass); - draggablePlaceholderElm?.parentElement?.classList.remove('slick-dropzone-placeholder-hover'); - const droppedGroupingElms = dropzoneElm.querySelectorAll('.slick-dropped-grouping'); - droppedGroupingElms.forEach((el) => (el.style.display = 'flex')); - if (droppedGroupingElms.length) { - if (draggablePlaceholderElm) { - draggablePlaceholderElm.style.display = 'none'; - } - if (groupTogglerElm) { - groupTogglerElm.style.display = 'inline-flex'; - } - } + restoreDropzoneState(); if (!grid.getEditorLock().commitCurrentEdit()) { return; @@ -575,7 +566,9 @@ export class SlickDraggableGrouping { 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); + if (col) { + newGroupingOrder.push(col); + } }); this.columnsGroupBy = newGroupingOrder; this.updateGroupBy('sort-group'); diff --git a/packages/common/src/filter-conditions/__tests__/filterUtilities.spec.ts b/packages/common/src/filter-conditions/__tests__/filterUtilities.spec.ts index 051b7d6cf3..a9d9b64a40 100644 --- a/packages/common/src/filter-conditions/__tests__/filterUtilities.spec.ts +++ b/packages/common/src/filter-conditions/__tests__/filterUtilities.spec.ts @@ -82,7 +82,9 @@ describe('filterUtilities', () => { return { unsubscribe: () => { const idx = subscribers.indexOf(cb); - if (idx >= 0) subscribers.splice(idx, 1); + if (idx >= 0) { + subscribers.splice(idx, 1); + } }, }; }, @@ -130,7 +132,9 @@ describe('filterUtilities', () => { return { unsubscribe: () => { const idx = subscribers.indexOf(cb); - if (idx >= 0) subscribers.splice(idx, 1); + if (idx >= 0) { + subscribers.splice(idx, 1); + } }, }; }, @@ -179,7 +183,9 @@ describe('filterUtilities', () => { return { unsubscribe: () => { const idx = subscribers.indexOf(cb); - if (idx >= 0) subscribers.splice(idx, 1); + if (idx >= 0) { + subscribers.splice(idx, 1); + } }, }; }, diff --git a/packages/pdf-export/src/pdfExport.service.ts b/packages/pdf-export/src/pdfExport.service.ts index 5d7cf735b1..f43eb3ae6b 100644 --- a/packages/pdf-export/src/pdfExport.service.ts +++ b/packages/pdf-export/src/pdfExport.service.ts @@ -373,7 +373,9 @@ export class PdfExportService implements ExternalResource, BasePdfExportService // Use first column's dataRowBackgroundOffset for the row // Always use the correct column for background offset (first visible data column) let firstDataColIdx = 0; - if (this._hasGroupedItems) firstDataColIdx = 1; + if (this._hasGroupedItems) { + firstDataColIdx = 1; + } const firstColId = columns.filter((col: Column) => !col.excludeFromExport)[firstDataColIdx]?.id; const colOpt = firstColId ? columnExportOptionsCache[firstColId] : this._exportOptions; doc.rect( diff --git a/packages/sql/src/services/sql.service.ts b/packages/sql/src/services/sql.service.ts index ab417c56aa..5def33db6c 100644 --- a/packages/sql/src/services/sql.service.ts +++ b/packages/sql/src/services/sql.service.ts @@ -646,7 +646,9 @@ export class SqlService implements BackendService { /** Escapes SQL identifiers (table, column, etc.) based on the configured escape style. */ protected escapeIdentifier(identifier?: string): string { const escapeStyle = this.options?.identifierEscapeStyle || 'doubleQuote'; - if (!identifier) return ''; + if (!identifier) { + return ''; + } switch (escapeStyle) { case 'backtick': return `\`${String(identifier).replace(/`/g, '``')}\``; diff --git a/packages/utils/src/domUtils.ts b/packages/utils/src/domUtils.ts index 4e2adb1cb2..a3a019ee45 100644 --- a/packages/utils/src/domUtils.ts +++ b/packages/utils/src/domUtils.ts @@ -3,7 +3,13 @@ import type { InferDOMType } from './models/types.js'; import { isDefined } from './utils.js'; export type CSSStyleDeclarationReadonly = - 'length' | 'parentRule' | 'getPropertyPriority' | 'getPropertyValue' | 'item' | 'removeProperty' | 'setProperty'; + | 'length' + | 'parentRule' + | 'getPropertyPriority' + | 'getPropertyValue' + | 'item' + | 'removeProperty' + | 'setProperty'; export type CSSStyleDeclarationWritable = Omit; /** Calculate available space for each side of the DOM element */ @@ -87,7 +93,9 @@ export function classNameToList(className = ''): string[] { * @param obj - object containing 1 or more properties with DOM Elements */ export function destroyAllElementProps(obj: Record | null): void { - if (!obj || typeof obj !== 'object') return; // Guard clause for null/undefined + if (!obj || typeof obj !== 'object') { + return; // Guard clause for null/undefined + } Object.keys(obj).forEach((key) => { if (Array.isArray(obj[key])) { From 3819a46e4dad492cadc47e5c3c96e4b5cd418ee0 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 7 Jul 2026 12:03:11 -0400 Subject: [PATCH 18/19] chore: run Prettier format --- packages/utils/src/domUtils.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/utils/src/domUtils.ts b/packages/utils/src/domUtils.ts index a3a019ee45..69a684cdc0 100644 --- a/packages/utils/src/domUtils.ts +++ b/packages/utils/src/domUtils.ts @@ -3,13 +3,7 @@ import type { InferDOMType } from './models/types.js'; import { isDefined } from './utils.js'; export type CSSStyleDeclarationReadonly = - | 'length' - | 'parentRule' - | 'getPropertyPriority' - | 'getPropertyValue' - | 'item' - | 'removeProperty' - | 'setProperty'; + 'length' | 'parentRule' | 'getPropertyPriority' | 'getPropertyValue' | 'item' | 'removeProperty' | 'setProperty'; export type CSSStyleDeclarationWritable = Omit; /** Calculate available space for each side of the DOM element */ From 2ce018f54dc31f90f33c37c58c46c4460d6ee3be Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 8 Jul 2026 22:43:41 -0400 Subject: [PATCH 19/19] chore: cleanup code and remove duplicate code --- .../common/src/core/slickColumnReorderDrag.ts | 154 +++++++----------- 1 file changed, 62 insertions(+), 92 deletions(-) diff --git a/packages/common/src/core/slickColumnReorderDrag.ts b/packages/common/src/core/slickColumnReorderDrag.ts index cd3966f417..8c432ae937 100644 --- a/packages/common/src/core/slickColumnReorderDrag.ts +++ b/packages/common/src/core/slickColumnReorderDrag.ts @@ -1,6 +1,17 @@ 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. * @@ -20,6 +31,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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; @@ -33,6 +45,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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 = () => { @@ -46,13 +59,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } }; - const isOverDropzone = (el: HTMLElement | null | undefined): boolean => !!el?.closest?.(dropzoneSelector); - const toggleDropzoneHoverClass = (el: HTMLElement | null | undefined, isActive: boolean) => { const dropzone = el?.closest?.(dropzoneSelector) as HTMLElement | null; - if (dropzone) { - dropzone.classList.toggle(dropzoneHoverClass, isActive); - } + dropzone?.classList.toggle(dropzoneHoverClass, isActive); }; const clearDropzoneHoverClasses = () => { @@ -97,7 +106,6 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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. - // Include Wayland because Firefox on Linux Wayland may omit the X11 token. const isFfLinux = typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) && /linux/i.test(navigator.userAgent); const getColumnIds = (parent: HTMLElement): string[] => @@ -125,31 +133,33 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const viewportLeft = getOffset(viewportScrollContainerX).left; const containerRight = containerOffset.left + container.clientWidth; if (!columnScrollTimer && pageX > containerRight) { - columnScrollTimer = setInterval(scrollColumnsRight, 100); + columnScrollTimer = setInterval(scrollColumnsRight, INTERVAL_TIME); } else if (!columnScrollTimer && pageX < viewportLeft) { - columnScrollTimer = setInterval(scrollColumnsLeft, 100); + columnScrollTimer = setInterval(scrollColumnsLeft, INTERVAL_TIME); } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { stopAutoScroll(); } } }; - const clearDropzoneTarget = () => { - dropzoneTargetActive = false; - }; - + const clearDropzoneTarget = () => (dropzoneTargetActive = false); const clearFallbackGhost = () => { - if (dragGhost?.parentElement) { - dragGhost.parentElement.removeChild(dragGhost); - } + dragGhost?.parentElement?.removeChild(dragGhost); dragGhost = null; }; - const updateFallbackGhost = (clientX: number, clientY: number) => { - if (dragGhost) { - dragGhost.style.left = `${clientX}px`; - dragGhost.style.top = `${clientY}px`; - } + 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) => { @@ -166,6 +176,13 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest 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)) { @@ -215,9 +232,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const onDragEnd = (e: DragEvent) => { const draggedHeader = draggedEl; - if (draggedHeader) { - draggedHeader.classList.remove(dragActiveClass); - } + draggedHeader?.classList.remove(dragActiveClass); stopAutoScroll(); document.removeEventListener('drag', autoScrollHandler as EventListener); @@ -253,45 +268,29 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest options.onDragEnd(reorderedIds); } // clear stored original position - draggedEl = null; - originalParent = null; - originalNextSibling = null; - draggedColumnId = ''; - clearDropzoneTarget(); - clearDropzoneHoverClasses(); - _lastClientX = null; - pointerDragCommitted = false; + resetDragState(); }; // Firefox/Linux native HTML5 drag is broken; touch screens never fire HTML5 drag events. // All three start events (dragstart, mousedown, touchstart) share one handler. - /** 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 }; - }; - const onStart = (e: DragEvent | MouseEvent | TouchEvent) => { const { clientX, clientY } = getPointerPos(e); const eventTarget = e.target as HTMLElement | null; - if (isDragStartIgnoredTarget(eventTarget, e)) { + 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 - if (e.type === 'dragstart') { - e.preventDefault(); - } + cancelNativeDragIfNeeded(e); return; } // Common state setup @@ -378,9 +377,9 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest const viewportLeft = getOffset(viewportScrollContainerX).left; const containerRight = containerOffset.left + container.clientWidth; if (!columnScrollTimer && pageX > containerRight) { - columnScrollTimer = setInterval(scrollColumnsRight, 100); + columnScrollTimer = setInterval(scrollColumnsRight, INTERVAL_TIME); } else if (!columnScrollTimer && pageX < viewportLeft) { - columnScrollTimer = setInterval(scrollColumnsLeft, 100); + columnScrollTimer = setInterval(scrollColumnsLeft, INTERVAL_TIME); } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { stopAutoScroll(); } @@ -413,16 +412,18 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } }; - const onPointerUp = (e: MouseEvent | TouchEvent) => { + 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; - if (draggedHeader) { - draggedHeader.classList.remove(dragActiveClass); - } + draggedHeader?.classList.remove(dragActiveClass); stopAutoScroll(); const { clientX, clientY } = getPointerPos(e); @@ -453,17 +454,7 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest } else if (pointerDragCommitted) { options.onDragEnd(reorderedIds); } - draggedEl = null; - originalParent = null; - originalNextSibling = null; - draggedColumnId = ''; - _lastClientX = null; - dragStartX = null; - dragStartY = null; - pointerDragCommitted = false; - clearDropzoneTarget(); - clearDropzoneHoverClasses(); - clearFallbackGhost(); + resetDragState(); // Re-enable text selection after drag completes document.body.style.userSelect = ''; }; @@ -492,22 +483,11 @@ export function setupColumnReorderDrag(options: ColumnReorderDragOption): { dest parent.removeEventListener('touchstart', onStart as EventListener); } document.removeEventListener('drag', autoScrollHandler as EventListener); - 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); + cleanupPointerListeners(); document.removeEventListener('dragenter', onDropzoneDragEnter as EventListener); document.removeEventListener('dragleave', onDropzoneDragLeave as EventListener); stopAutoScroll(); - draggedEl = null; - originalParent = null; - originalNextSibling = null; - draggedColumnId = ''; - pointerDragCommitted = false; - clearDropzoneTarget(); - clearDropzoneHoverClasses(); - clearFallbackGhost(); + resetDragState(); [headerLeft, headerRight].forEach((parent) => Array.from(parent.querySelectorAll(draggableSelector)).forEach((el) => { el.draggable = false; @@ -614,16 +594,6 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro // 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. - /** Extract { clientX, clientY } from a mouse or touch event. - * For touchend/touchcancel, e.touches is empty – falls back to changedTouches. */ - const getPointerPos = (e: MouseEvent | TouchEvent) => { - if ('touches' in e) { - const t = e.touches[0] ?? e.changedTouches[0]; - return { clientX: t?.clientX ?? 0, clientY: t?.clientY ?? 0 }; - } - return { clientX: e.clientX, clientY: e.clientY }; - }; - const onPointerDown = (e: MouseEvent | TouchEvent) => { const pill = (e.target as HTMLElement).closest(itemSelector); if (pill) { @@ -681,12 +651,16 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro } }; - const onPointerUp = () => { + 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); @@ -729,11 +703,7 @@ export function setupDropzonePillDrag(options: DropzonePillDragOption): { destro if (isFfLinux) { dropzoneElm.removeEventListener('mousedown', onPointerDown as EventListener); } - 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); + cleanupPointerListeners(); draggedPill = null; dragStartX = null; dragStartY = null;