diff --git a/cypress/e2e/example-draggable-grouping-header-drop.cy.ts b/cypress/e2e/example-draggable-grouping-header-drop.cy.ts new file mode 100644 index 000000000..d88655a3a --- /dev/null +++ b/cypress/e2e/example-draggable-grouping-header-drop.cy.ts @@ -0,0 +1,40 @@ +// Characterization test for dragging a column HEADER into the grouping dropzone (currently SortableJS +// cross-list drag with `group: { name: 'shared', pull: 'clone', put: false }` on the headers and +// `group: 'shared'` on the dropzone). This is the behavior the SortableJS removal refactor must +// reproduce in the SlickDraggableGrouping plugin, and it must pass identically before and after. +describe('Example - Draggable Grouping - drop a header into the dropzone (characterization)', { retries: 1 }, () => { + const GRID_ROW_HEIGHT = 25; + + it('should load the example and clear the initial grouping', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-draggable-grouping.html`); + cy.get('[data-test="clear-grouping-btn"]').click(); + + cy.get('#myGrid .slick-placeholder') + .should('be.visible') + .should('have.text', 'Drop a column header here to group by the column :)'); + cy.get('.slick-dropped-grouping').should('have.length', 0); + }); + + it('should drag the "Duration" column header into the dropzone and group by Duration', () => { + cy.get('#myGrid .slick-dropzone').first().then(($dropzone) => { + cy.contains('#myGrid .slick-header-column', 'Duration').drag($dropzone); + }); + + // a grouping pill is created in the pre-header dropzone + cy.get('.slick-dropped-grouping').should('have.length', 1); + cy.get('.slick-dropped-grouping:nth(0) div').contains('Duration'); + cy.get('#myGrid .slick-placeholder').should('not.be.visible'); + + // the header itself is not consumed by the drag (SortableJS pulls a clone) + cy.contains('#myGrid .slick-header-column', 'Duration').should('exist'); + + // and the grid data is actually grouped by Duration + cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration:'); + }); + + it('should clear the grouping again and expect the pill to be removed', () => { + cy.get('[data-test="clear-grouping-btn"]').click(); + cy.get('.slick-dropped-grouping').should('have.length', 0); + cy.get('#myGrid .slick-placeholder').should('be.visible'); + }); +}); diff --git a/cypress/e2e/example-frozen-columns-reorder.cy.ts b/cypress/e2e/example-frozen-columns-reorder.cy.ts new file mode 100644 index 000000000..6b1115c03 --- /dev/null +++ b/cypress/e2e/example-frozen-columns-reorder.cy.ts @@ -0,0 +1,204 @@ +import { createDragLikeEvent, pressPointer, releasePointer } from '../support/drag'; + +// Characterization tests for header column reordering on a frozen-columns grid (currently SortableJS). +// These specs pin down the observable behavior that must survive the SortableJS removal refactor: +// they are expected to pass identically before and after the drag engine is replaced. +describe('Example - Frozen Columns - Column Header Reorder (characterization)', { retries: 1 }, () => { + const LEFT_HEADERS = '#myGrid .slick-header-columns-left'; + const RIGHT_HEADERS = '#myGrid .slick-header-columns-right'; + const RIGHT_VIEWPORT = '#myGrid .slick-viewport-top.slick-viewport-right'; + + const initialLeftTitles = ['#', 'Title', 'Duration']; + const initialRightTitles = ['% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']; + const initialIds = ['sel', 'title', 'duration', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3', 'title4']; + + const expectHeaderTitles = (containerSelector: string, titles: string[]) => { + cy.get(containerSelector) + .children() + .should('have.length', titles.length) + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }; + + const expectColumnIds = (ids: string[]) => { + cy.window().then((win: any) => { + expect(win.grid.getColumns().map((c: any) => c.id)).to.deep.eq(ids); + }); + }; + + const expectReorderCallCount = (count: number) => { + cy.window().its('columnsReorderedCalls').should('have.length', count); + }; + + const getRightHeader = (win: any, title: string): HTMLElement => { + const headers = Array.from(win.document.querySelectorAll(`${RIGHT_HEADERS} .slick-header-column`)) as HTMLElement[]; + return headers.find((el) => (el.textContent ?? '').includes(title)) as HTMLElement; + }; + + it('should load the example and have the expected initial column order on both sides of the frozen boundary', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns.html`); + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + + // record every onColumnsReordered payload so specs can assert exactly when and with what the event fires + cy.window().then((win: any) => { + win.columnsReorderedCalls = []; + win.grid.onColumnsReordered.subscribe((_e: any, args: any) => { + win.columnsReorderedCalls.push({ + impactedColumnIds: args.impactedColumns.map((c: any) => c.id), + previousColumnOrder: [...args.previousColumnOrder], + }); + }); + }); + }); + + it('should reorder columns within the frozen (left) section', () => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').then(($target) => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').drag($target); + }); + + expectHeaderTitles(LEFT_HEADERS, ['#', 'Duration', 'Title']); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(['sel', 'duration', 'title', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3', 'title4']); + + expectReorderCallCount(1); + cy.window().then((win: any) => { + expect(win.columnsReorderedCalls[0].previousColumnOrder).to.deep.eq(initialIds); + }); + + // drag back (leftward) to restore the initial order + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').then(($target) => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').drag($target); + }); + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectColumnIds(initialIds); + expectReorderCallCount(2); + }); + + it('should reorder columns within the non-frozen (right) section and re-render the data cells accordingly', () => { + cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/01/2009'); // Start + cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/05/2009'); // Finish + + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Finish').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); + }); + + expectHeaderTitles(RIGHT_HEADERS, ['% Complete', 'Finish', 'Start', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']); + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectColumnIds(['sel', 'title', 'duration', '%', 'finish', 'start', 'effort-driven', 'title1', 'title2', 'title3', 'title4']); + + cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/05/2009'); // Finish now first + cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/01/2009'); // Start now second + + expectReorderCallCount(3); + + // drag back (leftward) to restore the initial order + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Finish').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); + }); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + expectReorderCallCount(4); + }); + + it('should NOT allow dragging a frozen (left) column into the non-frozen (right) section', () => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').then(($target) => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').drag($target); + }); + + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + expectReorderCallCount(4); + }); + + it('should NOT allow dragging a non-frozen (right) column into the frozen (left) section', () => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); + }); + + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + expectReorderCallCount(4); + }); + + it('should keep the horizontal scroll position after reordering columns in the scrolled right section', () => { + cy.get(RIGHT_VIEWPORT).scrollTo(300, 0, { ensureScrollable: false }); + cy.wait(50); + cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.be.closeTo(300, 2)); + + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title3').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title2').drag($target); + }); + + expectHeaderTitles(RIGHT_HEADERS, ['% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title3', 'Title2', 'Title4']); + expectReorderCallCount(5); + + // without the scroll restore, setColumns() would reset the viewport back to x=0 + cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.be.closeTo(300, 2)); + + // restore order and scroll position + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title3').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title2').drag($target); + }); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectReorderCallCount(6); + cy.get(RIGHT_VIEWPORT).scrollTo(0, 0, { ensureScrollable: false }); + }); + + it('should auto-scroll the right viewport when a header drag starts beyond the right edge of the grid', () => { + cy.get(RIGHT_VIEWPORT).scrollTo(0, 0, { ensureScrollable: false }); + cy.wait(50); + cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.eq(0)); + + // start a drag on "Finish" with the pointer already past the grid's right edge, then also fire a + // document-level `drag` event at those coordinates (that is what a real browser does continuously + // during a native drag, and what the future native reorder engine listens to for auto-scrolling) + cy.window().then((win: any) => { + const finishHeader = getRightHeader(win, 'Finish'); + expect(finishHeader).to.exist; + const rect = finishHeader.getBoundingClientRect(); + const sy = rect.top + rect.height / 2; + const dragX = (win.document.querySelector('#myGrid') as HTMLElement).clientWidth + 100; + const dataTransfer = new DataTransfer(); + + pressPointer(finishHeader, rect.left + rect.width / 2, sy); + finishHeader.dispatchEvent(createDragLikeEvent('dragstart', dragX, sy, dataTransfer)); + win.document.dispatchEvent(createDragLikeEvent('drag', dragX, sy, dataTransfer)); + }); + + cy.wait(250); + cy.window().then((win: any) => { + const finishHeader = getRightHeader(win, 'Finish'); + const sy = finishHeader.getBoundingClientRect().top; + const dragX = (win.document.querySelector('#myGrid') as HTMLElement).clientWidth + 100; + win.document.dispatchEvent(createDragLikeEvent('drag', dragX, sy, new DataTransfer())); + }); + cy.wait(250); + + cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.be.greaterThan(10)); + + // end the drag on the source itself: no reorder, and the auto-scroll must stop + cy.window().then((win: any) => { + const finishHeader = getRightHeader(win, 'Finish'); + const rect = finishHeader.getBoundingClientRect(); + const sy = rect.top + rect.height / 2; + const dragX = (win.document.querySelector('#myGrid') as HTMLElement).clientWidth + 100; + finishHeader.dispatchEvent(createDragLikeEvent('dragend', dragX, sy, new DataTransfer())); + releasePointer(finishHeader, dragX, sy); + }); + + cy.get(RIGHT_VIEWPORT).then(($v) => { + const scrollLeftAfterDrop = $v[0].scrollLeft; + cy.wait(300); + cy.get(RIGHT_VIEWPORT).should(($v2) => expect($v2[0].scrollLeft).to.eq(scrollLeftAfterDrop)); + }); + + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + expectReorderCallCount(6); + + cy.get(RIGHT_VIEWPORT).scrollTo(0, 0, { ensureScrollable: false }); + }); +}); diff --git a/cypress/e2e/example-grid-menu.cy.ts b/cypress/e2e/example-grid-menu.cy.ts index b77a5c400..9aa0454ae 100644 --- a/cypress/e2e/example-grid-menu.cy.ts +++ b/cypress/e2e/example-grid-menu.cy.ts @@ -1,5 +1,3 @@ -import '@4tw/cypress-drag-drop'; - describe('Example - Grid Menu', () => { const fullTitles = ['', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']; diff --git a/cypress/e2e/example14-column-reorder.cy.ts b/cypress/e2e/example14-column-reorder.cy.ts new file mode 100644 index 000000000..b731e6439 --- /dev/null +++ b/cypress/e2e/example14-column-reorder.cy.ts @@ -0,0 +1,163 @@ +// Characterization tests for header column reordering (currently implemented with SortableJS). +// These specs pin down the observable behavior that must survive the SortableJS removal refactor: +// they are expected to pass identically before and after the drag engine is replaced. +describe('Example 14 - Column Header Reorder (characterization)', { retries: 1 }, () => { + const initialTitles = ['Server', 'CPU0', 'CPU1', 'CPU2', 'CPU3']; + const initialIds = ['server', 'cpu0', 'cpu1', 'cpu2', 'cpu3']; + + const expectHeaderTitles = (titles: string[]) => { + cy.get('#myGrid .slick-header-columns-left') + .children() + .should('have.length', titles.length) + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }; + + const expectColumnIds = (ids: string[]) => { + cy.window().then((win: any) => { + expect(win.grid.getColumns().map((c: any) => c.id)).to.deep.eq(ids); + }); + }; + + const expectReorderCallCount = (count: number) => { + cy.window().its('columnsReorderedCalls').should('have.length', count); + }; + + it('should load the example and have the expected initial column order', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example14-highlighting.html`); + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + + // record every onColumnsReordered payload so specs can assert exactly when and with what the event fires + cy.window().then((win: any) => { + win.columnsReorderedCalls = []; + win.grid.onColumnsReordered.subscribe((_e: any, args: any) => { + win.columnsReorderedCalls.push({ + impactedColumnIds: args.impactedColumns.map((c: any) => c.id), + previousColumnOrder: [...args.previousColumnOrder], + }); + }); + }); + }); + + it('should drag CPU0 onto CPU2 and reorder it after CPU2, firing onColumnsReordered with the correct payload', () => { + cy.contains('#myGrid .slick-header-column', 'CPU2').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU0').drag($target); + }); + + expectHeaderTitles(['Server', 'CPU1', 'CPU2', 'CPU0', 'CPU3']); + expectColumnIds(['server', 'cpu1', 'cpu2', 'cpu0', 'cpu3']); + + // the data cells are re-rendered under the new column order + cy.get('#myGrid .grid-canvas [style*="top: 0px;"] > .slick-cell.l0.r0').should('contain', 'Server 0'); + + expectReorderCallCount(1); + cy.window().then((win: any) => { + const call = win.columnsReorderedCalls[0]; + expect(call.previousColumnOrder).to.deep.eq(initialIds); + expect(call.impactedColumnIds).to.deep.eq(['server', 'cpu1', 'cpu2', 'cpu0', 'cpu3']); + }); + }); + + it('should drag CPU0 back onto CPU1 (leftward) and restore the initial column order', () => { + cy.contains('#myGrid .slick-header-column', 'CPU1').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU0').drag($target); + }); + + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + + expectReorderCallCount(2); + cy.window().then((win: any) => { + const call = win.columnsReorderedCalls[1]; + expect(call.previousColumnOrder).to.deep.eq(['server', 'cpu1', 'cpu2', 'cpu0', 'cpu3']); + expect(call.impactedColumnIds).to.deep.eq(initialIds); + }); + }); + + it('should not reorder nor fire onColumnsReordered when a column is dropped onto itself', () => { + cy.contains('#myGrid .slick-header-column', 'CPU0').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU0').drag($target); + }); + + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + expectReorderCallCount(2); + }); + + it('should not allow dragging the unorderable "Server" column', () => { + cy.get('#myGrid .slick-header-column:nth(0)').should('have.class', 'unorderable'); + + cy.contains('#myGrid .slick-header-column', 'CPU1').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'Server').drag($target); + }); + + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + expectReorderCallCount(2); + }); + + it('should not allow dropping a column onto the unorderable "Server" column', () => { + cy.contains('#myGrid .slick-header-column', 'Server').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU1').drag($target); + }); + + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + expectReorderCallCount(2); + }); + + it('should still allow resizing the unorderable "Server" column', () => { + cy.get('#myGrid .slick-header-column:nth(0)').then(($header) => { + const widthBefore = $header.outerWidth() as number; + + cy.get('#myGrid .slick-header-column:nth(0) .slick-resizable-handle').then(($handle) => { + const rect = $handle[0].getBoundingClientRect(); + const startX = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + + cy.wrap($handle) + .trigger('mousedown', { which: 1, button: 0, clientX: startX, clientY: y, pageX: startX, pageY: y, force: true }); + cy.get('body') + .trigger('mousemove', { clientX: startX + 40, clientY: y, pageX: startX + 40, pageY: y, force: true }) + .trigger('mousemove', { clientX: startX + 40, clientY: y, pageX: startX + 40, pageY: y, force: true }) + .trigger('mouseup', { clientX: startX + 40, clientY: y, pageX: startX + 40, pageY: y, force: true }); + }); + + cy.get('#myGrid .slick-header-column:nth(0)').should(($headerAfter) => { + expect($headerAfter.outerWidth()).to.be.greaterThan(widthBefore + 20); + }); + }); + + expectHeaderTitles(initialTitles); + expectReorderCallCount(2); + }); + + // The old SortableJS implementation silently dropped hidden columns from the grid after any reorder, + // because hidden columns get no header element rendered and the toArray() read-back omitted them. + // The native reorder engine fixes this with column-map reconciliation. + it('should keep hidden columns in the column set when reordering', () => { + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + cols[2].hidden = true; // hide "CPU1" + win.grid.setColumns(cols); + }); + + cy.contains('#myGrid .slick-header-column', 'CPU2').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU0').drag($target); + }); + + cy.window().then((win: any) => { + const ids = win.grid.getColumns().map((c: any) => c.id); + expect(ids).to.have.length(5); + expect(ids).to.include('cpu1'); // the hidden column must not be lost + }); + + // restore for any subsequent spec + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + const hiddenCol = cols.find((c: any) => c.id === 'cpu1'); + if (hiddenCol) { hiddenCol.hidden = false; } + win.grid.setColumns(cols); + }); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 5f009efa2..475e2ff49 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -25,6 +25,7 @@ // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) import '@4tw/cypress-drag-drop'; import 'cypress-real-events'; +import './drag'; // overwrites the `drag` command from "@4tw/cypress-drag-drop" with our HTML5 DnD event sequence import { convertPosition } from './common'; declare global { diff --git a/cypress/support/drag.ts b/cypress/support/drag.ts index 948811189..f59413304 100644 --- a/cypress/support/drag.ts +++ b/cypress/support/drag.ts @@ -5,6 +5,7 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; + drag(target: string | HTMLElement | JQuery, options?: { dropSide?: DropSide; }): Chainable; dragOutside(viewport?: string, ms?: number, px?: number, options?: { parentSelector?: string, scrollbarDimension?: number; rowHeight?: number; }): Chainable; dragStart(options?: { cellWidth?: number; cellHeight?: number; }): Chainable; dragCell(addRow: number, addCell: number, options?: { cellWidth?: number; cellHeight?: number; }): Chainable; @@ -13,6 +14,96 @@ declare global { } } +export type DropSide = 'auto' | 'center' | 'left' | 'right'; + +// elements the `drag` command can pick up when given an inner child (e.g. the header name span) +const DRAGGABLE_ITEM_SELECTOR = '.slick-header-column, .slick-dropped-grouping, [draggable="true"]'; + +/** Create a drag-family event (dragstart/dragenter/dragover/drop/dragend/drag) carrying a DataTransfer and real coordinates */ +export function createDragLikeEvent(eventName: string, x: number, y: number, dataTransfer: DataTransfer): 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; +} + +/** Dispatch the pointer/mouse press that precedes a native HTML5 drag (SortableJS only arms itself from pointerdown/mousedown) */ +export function pressPointer(el: HTMLElement, x: number, y: number): void { + const init = { bubbles: true, cancelable: true, button: 0, buttons: 1, clientX: x, clientY: y }; + el.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerId: 1, isPrimary: true, pointerType: 'mouse' })); + el.dispatchEvent(new MouseEvent('mousedown', init)); +} + +/** Dispatch the pointer/mouse release that follows a native HTML5 drag */ +export function releasePointer(el: HTMLElement, x: number, y: number): void { + const init = { bubbles: true, cancelable: true, button: 0, buttons: 0, clientX: x, clientY: y }; + el.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerId: 1, isPrimary: true, pointerType: 'mouse' })); + el.dispatchEvent(new MouseEvent('mouseup', init)); +} + +// Replace the `@4tw/cypress-drag-drop` simulation with our own HTML5 DnD event sequence (ported from Slickgrid-Universal). +// It dispatches pointerdown/mousedown -> dragstart -> dragenter/dragover -> drop -> dragend -> pointerup/mouseup with a +// shared DataTransfer and real coordinates, i.e. the same sequence a browser fires for a real drag. `dropSide` controls +// where on the target the drop lands; 'auto' aims decisively past the target's midpoint (75%/25%) so the before/after +// insertion intent is unambiguous regardless of which drag engine (SortableJS or native) interprets it. +Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, options: { dropSide?: DropSide; } = {}) => { + const dropSide: DropSide = options?.dropSide ?? 'auto'; + + return cy.wrap(subject, { log: false }).then(($source: JQuery) => { + const rawSourceElm = $source?.[0] as HTMLElement | undefined; + const sourceElm = rawSourceElm?.closest(DRAGGABLE_ITEM_SELECTOR) ?? rawSourceElm; + const targetChain = typeof target === 'string' ? cy.get(target, { log: false }) : cy.wrap(target, { log: false }); + + return targetChain.then(($target: any) => { + const rawTargetElm = ($target?.[0] ?? $target) as HTMLElement | undefined; + const targetElm = rawTargetElm?.closest?.(DRAGGABLE_ITEM_SELECTOR) as HTMLElement ?? rawTargetElm; + + if (!sourceElm || !targetElm) { + return cy.wrap($source, { log: false }); + } + + const dataTransfer = new DataTransfer(); + const sourceRect = sourceElm.getBoundingClientRect(); + const sourceX = sourceRect.left + sourceRect.width / 2; + const sourceY = sourceRect.top + sourceRect.height / 2; + + pressPointer(sourceElm, sourceX, sourceY); + sourceElm.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY, dataTransfer)); + + // SortableJS activates the drag on the next macrotask, so yield before dragging over the target + return cy.wait(20, { log: false }).then(() => { + const targetRect = targetElm.getBoundingClientRect(); + let side = dropSide; + if (side === 'auto') { + if (sourceElm.parentElement === targetElm.parentElement && sourceRect.left !== targetRect.left) { + side = sourceRect.left < targetRect.left ? 'right' : 'left'; + } else { + side = 'center'; + } + } + const fraction = side === 'right' ? 0.75 : side === 'left' ? 0.25 : 0.5; + const targetX = targetRect.left + targetRect.width * fraction; + const targetY = targetRect.top + targetRect.height / 2; + + targetElm.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY, dataTransfer)); + targetElm.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY, dataTransfer)); + + return cy.wait(20, { log: false }).then(() => { + targetElm.dispatchEvent(createDragLikeEvent('drop', targetX, targetY, dataTransfer)); + sourceElm.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY, dataTransfer)); + releasePointer(sourceElm, targetX, targetY); + return cy.wrap($source, { log: false }); + }); + }); + }); + }); +}); + // @ts-ignore Cypress.Commands.add('dragStart', { prevSubject: true }, (subject, { cellWidth = 80, cellHeight = 25 } = {}) => { return cy.wrap(subject).click({ force: true }) diff --git a/src/global.d.ts b/src/global.d.ts index 96e192615..24755df86 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -50,7 +50,7 @@ import type { SlickState } from './plugins/slick.state.js'; import type { SlickGroupItemMetadataProvider } from './slick.groupitemmetadataprovider.js'; import type { SlickRemoteModel } from './slick.remotemodel.js'; import type { SlickRemoteModelYahoo } from './slick.remotemodel-yahoo.js'; -import type { Draggable, MouseWheel, Resizable } from './slick.interactions.js'; +import type { Draggable, MouseWheel, Resizable, setupColumnReorderDrag } from './slick.interactions.js'; import type { Aggregators } from './slick.dataview.js'; import type { Editors } from './slick.editors.js'; import type { Formatters } from './slick.formatters.js'; @@ -115,6 +115,7 @@ declare global { CopyRange: typeof SlickCopyRange, DragExtendHandle: typeof SlickDragExtendHandle, Resizable: typeof Resizable, + setupColumnReorderDrag: typeof setupColumnReorderDrag, RowMoveManager: typeof SlickRowMoveManager, RowSelectionMode: typeof RowSelectionMode, CellSelectionMode: typeof CellSelectionMode, diff --git a/src/models/interactions.interface.ts b/src/models/interactions.interface.ts index 53b37cbe0..d7490be34 100644 --- a/src/models/interactions.interface.ts +++ b/src/models/interactions.interface.ts @@ -46,6 +46,60 @@ export interface DraggableOption { dragFromClassDetectArr?: Array } +export interface ColumnReorderDragOption { + /** CSS class applied to dragged header columns while drag is active */ + dragActiveClass?: string; + + /** CSS selector used to find draggable header items (default: `.slick-header-column`) */ + draggableSelector?: string; + + /** Left header container (.slick-header-columns-left) */ + headerLeft: HTMLElement; + + /** Right header container (.slick-header-columns-right) */ + headerRight: HTMLElement; + + /** Grid container - used for the right-edge auto-scroll boundary */ + container: HTMLElement; + + /** Scrollable viewport - used for the left-edge boundary and actual scrolling */ + viewportScrollContainerX: HTMLElement; + + /** Returns true when the grid has frozen columns (determines which pane can auto-scroll) */ + hasFrozenColumns: () => boolean; + + /** CSS class that marks a column as non-reorderable */ + unorderableColumnCssClass?: string; + + /** Dropzone selector used to detect external drop targets, e.g. draggable grouping (default: `.slick-dropzone`) */ + dropzoneSelector?: string; + + /** CSS class toggled while hovering an external dropzone (default: `slick-dropzone-hover`) */ + dropzoneHoverClass?: string; + + /** + * Generic filter to ignore drag starts from interactive descendants. + * - `string`: CSS selector used with `closest()` + * - `function`: return `true` to cancel drag start for this target + */ + dragStartFilter?: string | ((target: HTMLElement | null, event: DragEvent | MouseEvent | TouchEvent) => boolean); + + /** + * Called right after dragstart, before any DOM changes. + * Use this to snapshot column state that your onDragEnd callback needs. + */ + onDragStart?: (draggedEl: HTMLElement) => void; + + /** + * Called when drag ends with the new visible-column ID order read from the DOM. + * Responsible for applying the reorder (setColumns, trigger event, etc.). + */ + onDragEnd: (reorderedIds: string[]) => void; + + /** Called when the drag is dropped onto an external dropzone such as draggable grouping. */ + onDrop?: (draggedEl: HTMLElement, event: DragEvent | MouseEvent | TouchEvent, draggedColumnId?: string) => void; +} + export interface MouseWheelOption { /** optional DOM element to attach mousewheel values, if undefined we'll attach it to the "window" object */ element: HTMLElement | Document; diff --git a/src/slick.grid.ts b/src/slick.grid.ts index a4fc0d24f..16521cda4 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -1,6 +1,3 @@ -// @ts-ignore -import type { SortableEvent, SortableInstance, SortableOptions } from 'sortablejs'; - import type { AutoSize, CellPosition, @@ -95,7 +92,7 @@ import { WidthEvalMode as WidthEvalMode_, DragExtendHandle as DragExtendHandle_, } from './slick.core.js'; -import { Draggable as Draggable_, MouseWheel as MouseWheel_, Resizable as Resizable_ } from './slick.interactions.js'; +import { Draggable as Draggable_, MouseWheel as MouseWheel_, Resizable as Resizable_, setupColumnReorderDrag as setupColumnReorderDrag_ } from './slick.interactions.js'; // for (iife) load Slick methods from global Slick object, or use imports for (esm) const BindingEventService = IIFE_ONLY ? Slick.BindingEventService : BindingEventService_; @@ -116,6 +113,7 @@ const WidthEvalMode = IIFE_ONLY ? Slick.WidthEvalMode : WidthEvalMode_; const Draggable = IIFE_ONLY ? Slick.Draggable : Draggable_; const MouseWheel = IIFE_ONLY ? Slick.MouseWheel : MouseWheel_; const Resizable = IIFE_ONLY ? Slick.Resizable : Resizable_; +const setupColumnReorderDrag = IIFE_ONLY ? Slick.setupColumnReorderDrag : setupColumnReorderDrag_; const DragExtendHandle = IIFE_ONLY ? Slick.DragExtendHandle : DragExtendHandle_; /** @@ -551,8 +549,7 @@ export class SlickGrid = Column, O e protected slickDraggableInstance: InteractionBase | null = null; protected slickMouseWheelInstances: Array = []; protected slickResizableInstances: Array = []; - protected sortableSideLeftInstance?: SortableInstance; - protected sortableSideRightInstance?: SortableInstance; + protected _columnReorderDrag?: InteractionBase; protected logMessageCount = 0; protected logMessageMaxCount = 30; protected _pubSubService?: BasePubSub; @@ -649,7 +646,7 @@ export class SlickGrid = Column, O e /** * Processes the provided grid options (mixing in default settings as needed), - * validates required modules (for example, ensuring Sortable.js is loaded if column reordering is enabled), + * validates required modules (for example, ensuring the column reorder drag module is loaded if column reordering is enabled), * and creates all necessary DOM elements for the grid (including header containers, viewports, canvases, panels, etc.). * It also caches CSS if the container or its ancestors are hidden and calls finish. * @@ -677,8 +674,8 @@ export class SlickGrid = Column, O e this.updateColumnProps(); // validate loaded JavaScript modules against requested options - if (this._options.enableColumnReorder && (!Sortable || !Sortable.create)) { - throw new Error('SlickGrid requires Sortable.js module to be loaded'); + if (this._options.enableColumnReorder && typeof setupColumnReorderDrag === 'undefined') { + throw new Error(`Slick.setupColumnReorderDrag is undefined, make sure to import "slick.interactions.js"`); } this.editController = { @@ -1076,7 +1073,7 @@ export class SlickGrid = Column, O e * Destroy (dispose) of SlickGrid * * Unbinds all event handlers, cancels any active cell edits, triggers the onBeforeDestroy event, - * unregisters and destroys plugins, destroys sortable and other interaction instances, + * unregisters and destroys plugins, destroys column reorder and other interaction instances, * unbinds ancestor scroll events, removes CSS rules, unbinds events from all key DOM elements * (canvas, viewports, header, footer, etc.), empties the grid container, removes the grid’s uid class, * and clears all timers. Optionally, if shouldDestroyAllElements is true, @@ -1098,10 +1095,8 @@ export class SlickGrid = Column, O e this.unregisterPlugin(this.plugins[i]); } - if (this._options.enableColumnReorder && typeof this.sortableSideLeftInstance?.destroy === 'function') { - this.sortableSideLeftInstance?.destroy(); - this.sortableSideRightInstance?.destroy(); - } + this._columnReorderDrag?.destroy(); + this._columnReorderDrag = undefined; this.unbindAncestorScrollEvents(); this._bindingEventService.unbindByEventName(this._container, 'resize'); @@ -1921,75 +1916,63 @@ export class SlickGrid = Column, O e } /** - * Destroys any existing sortable instances and creates new ones on the left and right header - * containers using the Sortable library. Configures options including animation, - * drag handle selectors, auto-scroll, and callbacks (onStart, onEnd) that - * update the column order, set columns, trigger onColumnsReordered, and reapply column resizing. + * Destroys any existing column reorder drag instance and sets up native HTML5 drag & drop on the + * left and right header containers. Configures callbacks (onDragStart, onDragEnd) that snapshot + * the current column order, update the column order, set columns, trigger onColumnsReordered, + * and reapply column resizing. */ protected setupColumnReorder() { - this.sortableSideLeftInstance?.destroy(); - this.sortableSideRightInstance?.destroy(); - - let columnScrollTimer: any = null; + this._columnReorderDrag?.destroy(); - const scrollColumnsRight = () => this._viewportScrollContainerX.scrollLeft = this._viewportScrollContainerX.scrollLeft + 10; - const scrollColumnsLeft = () => this._viewportScrollContainerX.scrollLeft = this._viewportScrollContainerX.scrollLeft - 10; let prevColumnIds: Array = []; - - let canDragScroll = false; - const sortableOptions = { - animation: 50, - direction: 'horizontal', - chosenClass: 'slick-header-column-active', - ghostClass: 'slick-sortable-placeholder', - draggable: '.slick-header-column', - dragoverBubble: false, - preventOnFilter: false, // allow column to be resized even when they are not orderable - revertClone: true, - scroll: !this.hasFrozenColumns(), // enable auto-scroll - // lock unorderable columns by using a combo of filter + onMove - filter: `.${this._options.unorderableColumnCssClass}`, - onMove: (event: MouseEvent & { related: HTMLElement; }) => { - return !event.related.classList.contains(this._options.unorderableColumnCssClass as string); - }, - onStart: (e: SortableEvent) => { - e.item.classList.add('slick-header-column-active'); - canDragScroll = !this.hasFrozenColumns() || Utils.offset(e.item)!.left > Utils.offset(this._viewportScrollContainerX)!.left; - - if (canDragScroll && e.originalEvent.pageX > this._container.clientWidth) { - if (!(columnScrollTimer)) { - columnScrollTimer = window.setInterval(scrollColumnsRight, 100); - } - } else if (canDragScroll && e.originalEvent.pageX < Utils.offset(this._viewportScrollContainerX)!.left) { - if (!(columnScrollTimer)) { - columnScrollTimer = window.setInterval(scrollColumnsLeft, 100); - } - } else { - window.clearInterval(columnScrollTimer); - columnScrollTimer = null; - } + let columnMap: Map | undefined; + + this._columnReorderDrag = setupColumnReorderDrag({ + headerLeft: this._headerL, + headerRight: this._headerR, + container: this._container, + viewportScrollContainerX: this._viewportScrollContainerX, + hasFrozenColumns: () => this.hasFrozenColumns(), + draggableSelector: '.slick-header-column', + dragActiveClass: 'slick-header-column-active', + unorderableColumnCssClass: this._options.unorderableColumnCssClass, + onDragStart: () => { prevColumnIds = this.columns.map((c) => c.id); - }, - onEnd: (e: SortableEvent) => { - e.item.classList.remove('slick-header-column-active'); - clearInterval(columnScrollTimer); - const prevScrollLeft = this.scrollLeft; - if (!this.getEditorLock()?.commitCurrentEdit()) { + // create a map to track original column positions, hidden and reorderable states + 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; + }, + onDragEnd: (reorderedIds) => { + // when 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 while preserving hidden/non-reorderable columns at their original indices + const finalColumns: C[] = []; + let visibleIdx = 0; + for (let i = 0; i < this.columns.length; i++) { + const colInfo = columnMap.get(String(this.columns[i].id)); + if (colInfo?.hidden) { + finalColumns.push(colInfo.column); + } else if (colInfo?.reorderable && reorderedIdSet.has(String(this.columns[i].id))) { + finalColumns.push(reorderedColumns[visibleIdx++] ?? colInfo!.column); + } else { + finalColumns.push(colInfo!.column); + } } - e.stopPropagation(); - if (!this.arrayEquals(prevColumnIds, reorderedIds)) { - this.setColumns(reorderedColumns); + 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); this.trigger(this.onColumnsReordered, { impactedColumns: this.columns, previousColumnOrder: prevColumnIds }); @@ -1999,10 +1982,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); + }); } /** diff --git a/src/slick.interactions.ts b/src/slick.interactions.ts index f35897f67..329b9b0da 100644 --- a/src/slick.interactions.ts +++ b/src/slick.interactions.ts @@ -1,4 +1,4 @@ -import type { DragItem, DragPosition, ClassDetectElement, DraggableOption, MouseWheelOption, ResizableOption } from './models/index.js'; +import type { ColumnReorderDragOption, DragItem, DragPosition, ClassDetectElement, DraggableOption, MouseWheelOption, ResizableOption } from './models/index.js'; import { Utils as Utils_ } from './slick.core.js'; // for (iife) load Slick methods from global Slick object, or use imports for (esm) @@ -310,11 +310,542 @@ export function Resizable(options: ResizableOption) { return { destroy }; } +/** + * 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). + */ +function 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 (replaces the previous SortableJS dependency), + * ported from the Slickgrid-Universal implementation of the same feature. + * + * Handles: + * - Making orderable header columns draggable + * - Live DOM reordering during dragover (constrained to the column's own header container, + * so columns can never cross the frozen-column boundary) + * - Browser-edge auto-scroll (left / right) during drag + * - Firefox+Linux ghost rendering fix via explicit setDragImage, with a mouse-based fallback drag + * - Touch-based fallback drag for touch screens (which never fire HTML5 drag events) + * - Storing the dragged column's data-id in dataTransfer (for cross-container drops, e.g. draggable grouping) + * + * @param {Object} options + * @returns - setupColumnReorderDrag instance which includes destroy method + * @class setupColumnReorderDrag + */ +export function setupColumnReorderDrag(options: ColumnReorderDragOption) { + const { headerLeft, headerRight, container, viewportScrollContainerX, unorderableColumnCssClass } = options; + const dragActiveClass = options.dragActiveClass ?? 'slick-header-column-active'; + const draggableSelector = options.draggableSelector ?? '.slick-header-column'; + const dropzoneSelector = options.dropzoneSelector ?? '.slick-dropzone'; + const dropzoneHoverClass = options.dropzoneHoverClass ?? 'slick-dropzone-hover'; + const DRAG_THRESHOLD = 5; // pixels before we consider it a drag, not a click + const INTERVAL_TIME = 100; // ms for browser-edge auto-scroll + + let columnScrollTimer: ReturnType | undefined; + let draggedEl: HTMLElement | null = null; + let originalParent: Node | null = null; + let originalNextSibling: ChildNode | null = null; + let dropzoneTargetActive = false; + let draggedColumnId = ''; + let _lastClientX: number | null = null; + let dragGhost: HTMLElement | null = null; + let dragStartX: number | null = null; + let dragStartY: number | null = null; + let pointerDragCommitted = false; + + const isOverDropzone = (el: HTMLElement | null | undefined): boolean => !!el?.closest?.(dropzoneSelector); + const scrollColumnsRight = () => (viewportScrollContainerX.scrollLeft += 10); + const scrollColumnsLeft = () => (viewportScrollContainerX.scrollLeft -= 10); + const stopAutoScroll = () => { + clearInterval(columnScrollTimer); + columnScrollTimer = undefined; + }; + + const restoreDraggedToOriginalParent = () => { + if (originalParent && draggedEl && draggedEl.parentElement !== originalParent) { + originalParent.insertBefore(draggedEl, originalNextSibling as Node | null); + } + }; + + const toggleDropzoneHoverClass = (el: HTMLElement | null | undefined, isActive: boolean) => { + const dropzone = el?.closest?.(dropzoneSelector) as HTMLElement | null; + dropzone?.classList.toggle(dropzoneHoverClass, isActive); + }; + + const clearDropzoneHoverClasses = () => { + document.querySelectorAll(dropzoneSelector).forEach((el) => el.classList.remove(dropzoneHoverClass)); + }; + + const safely = (operation: () => void, onError?: (error: unknown) => void) => { + try { + operation(); + } catch (error) { + onError?.(error); + } + }; + + const isDragStartIgnoredTarget = (el: HTMLElement | null, event: DragEvent | MouseEvent | TouchEvent): boolean => { + const dragStartFilter = options.dragStartFilter; + return typeof dragStartFilter === 'function' + ? !!dragStartFilter(el, event) + : typeof dragStartFilter === 'string' && dragStartFilter.trim() + ? !!el?.closest?.(dragStartFilter) + : false; + }; + + const reorderDraggedAgainstTarget = (target: HTMLElement, clientX: number) => { + if (!draggedEl || target === draggedEl || !isDraggable(target)) { + return; + } + + // only allow reordering within the dragged column's own header container; this keeps + // columns from crossing the frozen-column boundary (same behavior as the previous + // SortableJS implementation which used two unconnected lists) + const targetParent = target.parentElement; + if (!targetParent || targetParent !== originalParent) { + return; + } + + const rect = target.getBoundingClientRect(); + const movingRight = _lastClientX === null ? clientX >= rect.left + rect.width / 2 : clientX > _lastClientX; + _lastClientX = clientX; + const insertBefore = !movingRight; + targetParent.insertBefore(draggedEl, insertBefore ? target : target.nextSibling); + }; + + const isDraggable = (el: HTMLElement): boolean => + el.matches(draggableSelector) && (!unorderableColumnCssClass || !el.classList.contains(unorderableColumnCssClass)); + + // Mirror SortableJS's Firefox/Linux fallback detection so the mouse-based path is used only for the broken browser combo. + const isFfLinux = typeof navigator !== 'undefined' && /firefox/i.test(navigator.userAgent) && /linux/i.test(navigator.userAgent); + + const getColumnIds = (parent: HTMLElement): string[] => + Array.from(parent.children) + .filter((el) => isDraggable(el as HTMLElement)) + .map((el) => (el as HTMLElement).dataset.id ?? '') + .filter(Boolean); + + // Set draggable attribute on all eligible header columns + const refreshDraggable = (parent: HTMLElement) => { + Array.from(parent.children as HTMLCollectionOf).forEach((el) => { + if (el.matches(draggableSelector)) { + // Disable native HTML5 drag on Firefox/Linux and use mouse fallback + el.draggable = isDraggable(el) && !isFfLinux; + } + }); + }; + refreshDraggable(headerLeft); + refreshDraggable(headerRight); + + const autoScrollHandler = (e: DragEvent) => { + const { clientX, clientY, pageX } = e; + if (clientX !== undefined && clientY !== undefined) { + const containerOffset = Utils.offset(container); + const viewportLeft = Utils.offset(viewportScrollContainerX)?.left ?? 0; + const containerRight = (containerOffset?.left ?? 0) + container.clientWidth; + if (!columnScrollTimer && pageX > containerRight) { + columnScrollTimer = setInterval(scrollColumnsRight, INTERVAL_TIME); + } else if (!columnScrollTimer && pageX < viewportLeft) { + columnScrollTimer = setInterval(scrollColumnsLeft, INTERVAL_TIME); + } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { + stopAutoScroll(); + } + } + }; + + const clearDropzoneTarget = () => (dropzoneTargetActive = false); + const clearFallbackGhost = () => { + dragGhost?.parentElement?.removeChild(dragGhost); + dragGhost = null; + }; + + const resetDragState = () => { + draggedEl = null; + originalParent = null; + originalNextSibling = null; + draggedColumnId = ''; + _lastClientX = null; + dragStartX = null; + dragStartY = null; + pointerDragCommitted = false; + clearDropzoneTarget(); + clearDropzoneHoverClasses(); + clearFallbackGhost(); + }; + + const createFallbackGhost = (source: HTMLElement, clientX: number, clientY: number) => { + clearFallbackGhost(); + const rect = source.getBoundingClientRect(); + dragGhost = source.cloneNode(true) as HTMLElement; + dragGhost.classList.add('slick-header-column-drag-ghost'); + // Explicitly set dimensions and styles so the ghost renders correctly outside its original container + dragGhost.style.width = `${rect.width}px`; + dragGhost.style.height = `${rect.height}px`; + dragGhost.style.opacity = '0.8'; + dragGhost.style.left = `${clientX}px`; + dragGhost.style.top = `${clientY}px`; + document.body.appendChild(dragGhost); + }; + + const updateFallbackGhost = (clientX: number, clientY: number) => { + if (dragGhost) { + dragGhost.style.left = `${clientX}px`; + dragGhost.style.top = `${clientY}px`; + } + }; + + const onDropzoneDragEnter = (e: Event) => { + const target = e.target as HTMLElement | null; + if (draggedEl && isOverDropzone(target)) { + dropzoneTargetActive = true; + toggleDropzoneHoverClass(target, true); + // Ensure the dragged header remains in the header DOM (non-destructive) + safely(() => { + restoreDraggedToOriginalParent(); + }); + } + }; + + const onDropzoneDragLeave = (e: DragEvent) => { + const target = e.target as HTMLElement | null; + const relatedTarget = + (e.relatedTarget as HTMLElement | null) ?? + (e.clientX !== undefined && e.clientY !== undefined ? (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null) : null); + if (isOverDropzone(target) && !isOverDropzone(relatedTarget)) { + dropzoneTargetActive = false; + toggleDropzoneHoverClass(target, false); + } + }; + + document.addEventListener('dragenter', onDropzoneDragEnter as EventListener); + document.addEventListener('dragleave', onDropzoneDragLeave as EventListener); + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + const overDropzone = isOverDropzone(e.target as HTMLElement | null); + if (overDropzone) { + dropzoneTargetActive = true; + toggleDropzoneHoverClass(e.target as HTMLElement | null, true); + // Keep the dragged header visible in the original header DOM while over the dropzone + safely(() => { + restoreDraggedToOriginalParent(); + }); + return; + } + + clearDropzoneHoverClasses(); + + const target = (e.target as HTMLElement).closest(draggableSelector); + if (target) { + reorderDraggedAgainstTarget(target, e.clientX); + } + }; + + const finalizeDrag = (e: DragEvent) => { + const draggedHeader = draggedEl; + draggedHeader?.classList.remove(dragActiveClass); + stopAutoScroll(); + document.removeEventListener('drag', autoScrollHandler as EventListener); + + // If the drop happened over an external dropzone (eg. DraggableGrouping), the header + // may have been moved out of the header DOM during dragover. Detect that and + // restore the header to its original parent to avoid permanently removing the column + // from the grid's column list when we read column order from the DOM. + let droppedOnDropzone = dropzoneTargetActive; + safely( + () => { + if (!droppedOnDropzone && e.clientX !== undefined && e.clientY !== undefined) { + const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; + if (el && el.closest && el.closest(dropzoneSelector)) { + droppedOnDropzone = true; + } + } + }, + () => { + droppedOnDropzone = false; + } + ); + + if (droppedOnDropzone && draggedHeader && originalParent) { + // restore the header DOM to its original location + originalParent.insertBefore(draggedHeader, originalNextSibling as Node | null); + } + + const reorderedIds = getColumnIds(headerLeft).concat(getColumnIds(headerRight)); + e.stopPropagation(); + if (droppedOnDropzone && draggedHeader) { + options.onDrop?.(draggedHeader, e, draggedColumnId); + } else { + options.onDragEnd(reorderedIds); + } + // clear stored original position + resetDragState(); + }; + + const onDragEnd = (e: DragEvent) => { + if (!draggedEl) { + // drag already finalized (e.g. by a `drop` on the headers) or never started + stopAutoScroll(); + document.removeEventListener('drag', autoScrollHandler as EventListener); + return; + } + finalizeDrag(e); + }; + + // finalize on `drop` too (like SortableJS did): some drag sources dispatch `drop` + // without a subsequent `dragend`, and finalizing here is a no-op for the dragend + // that normally follows since the drag state is reset + const onHeaderDrop = (e: DragEvent) => { + e.preventDefault(); + if (draggedEl) { + finalizeDrag(e); + } + }; + + // Firefox/Linux native HTML5 drag is broken; touch screens never fire HTML5 drag events. + // All three start events (dragstart, mousedown, touchstart) share one handler. + + const onStart = (e: DragEvent | MouseEvent | TouchEvent) => { + const { clientX, clientY } = getPointerPos(e); + const eventTarget = e.target as HTMLElement | null; + const cancelNativeDragIfNeeded = (evt: DragEvent | MouseEvent | TouchEvent) => { + if (evt.type === 'dragstart') { + evt.preventDefault(); + } + }; + + if (isDragStartIgnoredTarget(eventTarget, e)) { + cancelNativeDragIfNeeded(e); + return; + } + const target = eventTarget?.closest(draggableSelector); + if (!target || !isDraggable(target)) { + // Cancel a native drag that started on a non-orderable column + cancelNativeDragIfNeeded(e); + return; + } + // Common state setup + draggedEl = target; + draggedColumnId = target.dataset.id ?? ''; + clearDropzoneTarget(); + originalParent = target.parentElement; + originalNextSibling = target.nextSibling; + if (e.type === 'dragstart') { + options.onDragStart?.(target); + } + _lastClientX = clientX; + + if (e.type === 'dragstart') { + // Native HTML5 drag: configure dataTransfer and auto-scroll + // Add class immediately for native drag since it's committed + target.classList.add(dragActiveClass); + const de = e as DragEvent; + if (de.dataTransfer) { + de.dataTransfer.effectAllowed = 'move'; + // Store column id so the dropzone can identify which column was dragged + if (typeof de.dataTransfer.setData === 'function') { + de.dataTransfer.setData('text/plain', target.dataset.id ?? ''); + } + // Explicit drag image avoids Firefox+Linux ghost rendering issues. + // Use clientX/Y minus the target rect to get the offset relative to the + // actual column header element (e.offsetX/Y is relative to e.target which + // may be a child span, causing a wrong ghost position on Firefox/Linux). + if (typeof de.dataTransfer.setDragImage === 'function') { + const rect = target.getBoundingClientRect(); + de.dataTransfer.setDragImage(target, clientX - rect.left, clientY - rect.top); + } + } + // Only non-frozen columns should trigger browser-edge auto-scroll + const canAutoScroll = !options.hasFrozenColumns() || headerRight.contains(target); + if (canAutoScroll) { + document.addEventListener('drag', autoScrollHandler as EventListener); + } + } else { + // Pointer fallback (mouse on FF/Linux, touch on all platforms) + dragStartX = clientX; + dragStartY = clientY; + pointerDragCommitted = false; + if ('touches' in e) { + document.addEventListener('touchmove', onPointerMove as EventListener, { passive: false }); + document.addEventListener('touchend', onPointerUp as EventListener); + document.addEventListener('touchcancel', onPointerUp as EventListener); + } else { + document.addEventListener('mousemove', onPointerMove as EventListener); + document.addEventListener('mouseup', onPointerUp as EventListener); + } + } + }; + + const onPointerMove = (e: MouseEvent | TouchEvent) => { + if (draggedEl && dragStartX !== null && dragStartY !== null) { + const { clientX, clientY, pageX } = getPointerPos(e); + + // Check if we've exceeded the drag threshold + if (!pointerDragCommitted) { + const deltaX = Math.abs(clientX - dragStartX); + const deltaY = Math.abs(clientY - dragStartY); + if (deltaX < DRAG_THRESHOLD && deltaY < DRAG_THRESHOLD) { + // Haven't moved far enough yet - don't commit to drag + return; + } + // Threshold exceeded - now commit to drag + e.preventDefault(); + options.onDragStart?.(draggedEl); + createFallbackGhost(draggedEl, clientX, clientY); + draggedEl.classList.add(dragActiveClass); + // Disable text selection only after drag intent is confirmed + document.body.style.userSelect = 'none'; + pointerDragCommitted = true; + } + + // Always update ghost position for visual feedback once drag is committed + updateFallbackGhost(clientX, clientY); + + e.preventDefault(); + + // browser-edge auto-scroll + const containerOffset = Utils.offset(container); + const viewportLeft = Utils.offset(viewportScrollContainerX)?.left ?? 0; + const containerRight = (containerOffset?.left ?? 0) + container.clientWidth; + if (!columnScrollTimer && pageX > containerRight) { + columnScrollTimer = setInterval(scrollColumnsRight, INTERVAL_TIME); + } else if (!columnScrollTimer && pageX < viewportLeft) { + columnScrollTimer = setInterval(scrollColumnsLeft, INTERVAL_TIME); + } else if (columnScrollTimer && pageX <= containerRight && pageX >= viewportLeft) { + stopAutoScroll(); + } + + const elUnder = (() => { + // Hide ghost temporarily so elementFromPoint doesn't hit it + if (dragGhost) { + dragGhost.style.display = 'none'; + } + const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + if (dragGhost) { + dragGhost.style.display = ''; + } + return el; + })(); + const overDropzone = isOverDropzone(elUnder); + if (overDropzone) { + dropzoneTargetActive = true; + toggleDropzoneHoverClass(elUnder, true); + } else { + dropzoneTargetActive = false; + clearDropzoneHoverClasses(); + const targetHeader = elUnder?.closest?.(draggableSelector) as HTMLElement | null; + if (targetHeader) { + safely(() => { + reorderDraggedAgainstTarget(targetHeader, clientX); + }); + } + } + } + }; + + const cleanupPointerListeners = () => { + document.removeEventListener('mousemove', onPointerMove as EventListener); + document.removeEventListener('mouseup', onPointerUp as EventListener); + document.removeEventListener('touchmove', onPointerMove as EventListener); + document.removeEventListener('touchend', onPointerUp as EventListener); + document.removeEventListener('touchcancel', onPointerUp as EventListener); + }; + + const onPointerUp = (e: MouseEvent | TouchEvent) => { + cleanupPointerListeners(); + const draggedHeader = draggedEl; + draggedHeader?.classList.remove(dragActiveClass); + stopAutoScroll(); + + const { clientX, clientY } = getPointerPos(e); + let droppedOnDropzone = dropzoneTargetActive; + safely( + () => { + if (!droppedOnDropzone) { + const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + if (el?.closest?.(dropzoneSelector)) { + droppedOnDropzone = true; + } + } + }, + () => { + droppedOnDropzone = false; + } + ); + const originalParentNode = originalParent; + const originalSiblingNode = originalNextSibling; + if (droppedOnDropzone && draggedHeader && originalParentNode) { + safely(() => { + originalParentNode.insertBefore(draggedHeader, originalSiblingNode as Node | null); + }); + } + const reorderedIds = getColumnIds(headerLeft).concat(getColumnIds(headerRight)); + if (pointerDragCommitted && droppedOnDropzone && draggedHeader) { + options.onDrop?.(draggedHeader, e, draggedColumnId); + } else if (pointerDragCommitted) { + options.onDragEnd(reorderedIds); + } + resetDragState(); + // Re-enable text selection after drag completes + document.body.style.userSelect = ''; + }; + + for (const parent of [headerLeft, headerRight]) { + parent.addEventListener('dragstart', onStart as EventListener); + parent.addEventListener('dragover', onDragOver as EventListener); + parent.addEventListener('dragend', onDragEnd as EventListener); + parent.addEventListener('drop', onHeaderDrop as EventListener); + if (isFfLinux) { + // Mouse-based fallback for Firefox on Linux + parent.addEventListener('mousedown', onStart as EventListener, true); + } + // Touch fallback for all platforms (touch screens don't fire HTML5 drag events) + parent.addEventListener('touchstart', onStart as EventListener, { passive: false }); + } + + function destroy() { + for (const parent of [headerLeft, headerRight]) { + parent.removeEventListener('dragstart', onStart as EventListener); + parent.removeEventListener('dragover', onDragOver as EventListener); + parent.removeEventListener('dragend', onDragEnd as EventListener); + parent.removeEventListener('drop', onHeaderDrop as EventListener); + if (isFfLinux) { + parent.removeEventListener('mousedown', onStart as EventListener, true); + } + parent.removeEventListener('touchstart', onStart as EventListener); + } + document.removeEventListener('drag', autoScrollHandler as EventListener); + cleanupPointerListeners(); + document.removeEventListener('dragenter', onDropzoneDragEnter as EventListener); + document.removeEventListener('dragleave', onDropzoneDragLeave as EventListener); + stopAutoScroll(); + resetDragState(); + [headerLeft, headerRight].forEach((parent) => + Array.from(parent.querySelectorAll(draggableSelector)).forEach((el) => { + el.draggable = false; + el.classList.remove(dragActiveClass); + }) + ); + } + + // public API + return { destroy }; +} + // extend Slick namespace on window object when building as iife if (IIFE_ONLY && window.Slick) { Utils.extend(Slick, { Draggable, MouseWheel, Resizable, + setupColumnReorderDrag, }); } diff --git a/src/styles/slick.grid.scss b/src/styles/slick.grid.scss index 39ccd548f..8a218fb98 100644 --- a/src/styles/slick.grid.scss +++ b/src/styles/slick.grid.scss @@ -273,3 +273,13 @@ classes should alter those! outline: 0; width: 100%; } + +/* fallback ghost shown while dragging a column header via the mouse/touch fallback + (Firefox on Linux, touch screens), where the native HTML5 drag image is unavailable */ +.slick-header-column-drag-ghost { + position: fixed; + margin: 0; + pointer-events: none; + z-index: 9999; + transform: translate(-8px, -8px); +}