diff --git a/examples/vite-demo-vanilla-bundle/src/app-routing.ts b/examples/vite-demo-vanilla-bundle/src/app-routing.ts index 29c583c70..8cb41d816 100644 --- a/examples/vite-demo-vanilla-bundle/src/app-routing.ts +++ b/examples/vite-demo-vanilla-bundle/src/app-routing.ts @@ -19,6 +19,7 @@ import Example15 from './examples/example15'; import Example16 from './examples/example16'; import Example17 from './examples/example17'; import Example18 from './examples/example18'; +import Example19 from './examples/example19'; export class AppRouting { constructor(private config: RouterConfig) { @@ -43,6 +44,7 @@ export class AppRouting { { route: 'example16', name: 'example16', view: './examples/example16.html', viewModel: Example16, title: 'Example16', }, { route: 'example17', name: 'example17', view: './examples/example17.html', viewModel: Example17, title: 'Example17', }, { route: 'example18', name: 'example18', view: './examples/example18.html', viewModel: Example18, title: 'Example18', }, + { route: 'example19', name: 'example19', view: './examples/example19.html', viewModel: Example19, title: 'Example19', }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' } ]; diff --git a/examples/vite-demo-vanilla-bundle/src/app.html b/examples/vite-demo-vanilla-bundle/src/app.html index 994850756..ec4ba32ad 100644 --- a/examples/vite-demo-vanilla-bundle/src/app.html +++ b/examples/vite-demo-vanilla-bundle/src/app.html @@ -89,6 +89,9 @@

Slickgrid-Universal

Example18 - Real-Time Trading Platform + + Example19 - ExcelCopyBuffer with Cell Selection + diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example19.html b/examples/vite-demo-vanilla-bundle/src/examples/example19.html new file mode 100644 index 000000000..25559ac56 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example19.html @@ -0,0 +1,27 @@ +

+ Example 19 - ExcelCopyBuffer with Cell Selection + (with Salesforce Theme) +
+ see + + code + +
+

+ +
+ Grid - using enableExcelCopyBuffer which uses SlickCellSelectionModel +
+
+ + + +
+ +
+
\ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example19.scss b/examples/vite-demo-vanilla-bundle/src/examples/example19.scss new file mode 100644 index 000000000..4d6b5cc83 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example19.scss @@ -0,0 +1,10 @@ +/** override slick-cell to make it look like Excel sheet */ +.grid19 { + --slick-border-color: #d4d4d4; + --slick-cell-odd-background-color: #fbfbfb; + --slick-cell-border-left: 1px solid var(--slick-border-color); + --slick-header-menu-display: none; + --slick-header-column-height: 20px; + --slick-grid-border-color: #d4d4d4; + --slick-row-selected-color: #d4ebfd; +} \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example19.ts b/examples/vite-demo-vanilla-bundle/src/examples/example19.ts new file mode 100644 index 000000000..6c4a4b18f --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example19.ts @@ -0,0 +1,132 @@ +import { CellRange, Column, GridOption, SlickEventHandler, SlickNamespace, } from '@slickgrid-universal/common'; +import { Slicker, SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; +import { ExampleGridOptions } from './example-grid-options'; +import '../salesforce-styles.scss'; +import './example19.scss'; + +const NB_ITEMS = 100; +declare const Slick: SlickNamespace; +export default class Example34 { + protected _eventHandler: SlickEventHandler; + title = 'Example 19: ExcelCopyBuffer with Cell Selection'; + subTitle = `Cell Selection using "Shift+{key}" where "key" can be any of: + `; + + columnDefinitions: Column[] = []; + dataset: any[] = []; + gridOptions!: GridOption; + gridContainerElm: HTMLDivElement; + isWithPagination = true; + sgb: SlickVanillaGridBundle; + + attached() { + this._eventHandler = new Slick.EventHandler(); + + // define the grid options & columns and then create the grid itself + this.defineGrid(); + + // mock some data (different in each dataset) + this.dataset = this.getData(NB_ITEMS); + this.gridContainerElm = document.querySelector(`.grid19`) as HTMLDivElement; + this.sgb = new Slicker.GridBundle(document.querySelector(`.grid19`) as HTMLDivElement, this.columnDefinitions, { ...ExampleGridOptions, ...this.gridOptions }, this.dataset); + document.body.classList.add('salesforce-theme'); + + // bind any of the grid events + const cellSelectionModel = this.sgb.slickGrid!.getSelectionModel(); + this._eventHandler.subscribe(cellSelectionModel!.onSelectedRangesChanged, (_e, args: CellRange[]) => { + const targetRange = document.querySelector('#selectionRange') as HTMLSpanElement; + targetRange.textContent = ''; + + for (const slickRange of args) { + targetRange.textContent += JSON.stringify(slickRange); + } + }); + } + + dispose() { + this._eventHandler.unsubscribeAll(); + this.sgb?.dispose(); + this.gridContainerElm.remove(); + document.body.classList.remove('salesforce-theme'); + } + + /* Define grid Options and Columns */ + defineGrid() { + this.columnDefinitions = [ + { + id: 'selector', + name: '', + field: 'num', + width: 30 + } + ]; + + for (let i = 0; i < NB_ITEMS; i++) { + this.columnDefinitions.push({ + id: i, + name: i < 26 + ? String.fromCharCode('A'.charCodeAt(0) + (i % 26)) + : String.fromCharCode('A'.charCodeAt(0) + ((i / 26) | 0) -1) + String.fromCharCode('A'.charCodeAt(0) + (i % 26)), + field: i as any, + minWidth: 60, + width: 60, + }); + } + + this.gridOptions = { + autoResize: { + container: '.demo-container', + }, + enableCellNavigation: true, + enablePagination: true, + pagination: { + pageSizes: [5, 10, 15, 20, 25, 50, 75, 100], + pageSize: 20 + }, + headerRowHeight: 35, + rowHeight: 30, + + // when using the ExcelCopyBuffer, you can see what the selection range is + enableExcelCopyBuffer: true, + // excelCopyBufferOptions: { + // onCopyCells: (e, args: { ranges: SelectedRange[] }) => console.log('onCopyCells', args.ranges), + // onPasteCells: (e, args: { ranges: SelectedRange[] }) => console.log('onPasteCells', args.ranges), + // onCopyCancelled: (e, args: { ranges: SelectedRange[] }) => console.log('onCopyCancelled', args.ranges), + // } + }; + } + + getData(itemCount: number) { + // mock a dataset + const datasetTmp: any[] = []; + for (let i = 0; i < itemCount; i++) { + const d: any = (datasetTmp[i] = {}); + d['id'] = i; + d['num'] = i; + } + + return datasetTmp; + } + + generatePhoneNumber(): string { + let phone = ''; + for (let i = 0; i < 10; i++) { + phone += Math.round(Math.random() * 9) + ''; + } + return phone; + } + + // Toggle the Grid Pagination + // IMPORTANT, the Pagination MUST BE CREATED on initial page load before you can start toggling it + // Basically you cannot toggle a Pagination that doesn't exist (must created at the time as the grid) + togglePagination() { + this.isWithPagination = !this.isWithPagination; + this.sgb.paginationService!.togglePaginationVisibility(this.isWithPagination); + this.sgb.slickGrid!.setSelectedRows([]); + } +} diff --git a/packages/common/src/extensions/__tests__/slickCellSelectionModel.spec.ts b/packages/common/src/extensions/__tests__/slickCellSelectionModel.spec.ts index 78379135d..1d692a59a 100644 --- a/packages/common/src/extensions/__tests__/slickCellSelectionModel.spec.ts +++ b/packages/common/src/extensions/__tests__/slickCellSelectionModel.spec.ts @@ -6,6 +6,8 @@ import { SlickCellSelectionModel } from '../slickCellSelectionModel'; declare const Slick: SlickNamespace; const GRID_UID = 'slickgrid_12345'; +const NB_ITEMS = 200; +const CALCULATED_PAGE_ROW_COUNT = 23; // pageRowCount with our mocked sizes is 23 => ((600 - 17) / 25) jest.mock('flatpickr', () => { }); const addVanillaEventPropagation = function (event, commandKey = '', keyName = '') { @@ -23,6 +25,7 @@ const addVanillaEventPropagation = function (event, commandKey = '', keyName = ' const mockGridOptions = { frozenColumn: 1, frozenRow: -1, + rowHeight: 25 } as GridOption; const getEditorLockMock = { @@ -30,6 +33,11 @@ const getEditorLockMock = { isActive: jest.fn(), }; +const dataViewStub = { + getLength: () => NB_ITEMS, + getPagingInfo: () => ({ pageSize: 0 }), +}; + const gridStub = { canCellBeSelected: jest.fn(), getActiveCell: jest.fn(), @@ -38,9 +46,13 @@ const gridStub = { getCellFromEvent: jest.fn(), getCellFromPoint: jest.fn(), getCellNodeBox: jest.fn(), + getData: () => dataViewStub, + getDataLength: jest.fn(), getEditorLock: () => getEditorLockMock, getOptions: () => mockGridOptions, getUID: () => GRID_UID, + getScrollbarDimensions: () => ({ height: 17, width: 17}), + getViewportNode: jest.fn(), focus: jest.fn(), registerPlugin: jest.fn(), setActiveCell: jest.fn(), @@ -81,6 +93,8 @@ describe('CellSelectionModel Plugin', () => { beforeEach(() => { plugin = new SlickCellSelectionModel(); + jest.spyOn(gridStub, 'getViewportNode').mockReturnValue(viewportElm); + Object.defineProperty(viewportElm, 'clientHeight', { writable: true, configurable: true, value: 600 }); }); afterEach(() => { @@ -109,7 +123,6 @@ describe('CellSelectionModel Plugin', () => { plugin.init(gridStub); expect(plugin.cellRangeSelector).toBeTruthy(); - expect(plugin.canvas).toBeTruthy(); expect(plugin.addonOptions).toEqual({ selectActiveCell: true }); expect(registerSpy).toHaveBeenCalledWith(plugin.cellRangeSelector); }); @@ -121,7 +134,6 @@ describe('CellSelectionModel Plugin', () => { plugin.init(gridStub); expect(plugin.cellRangeSelector).toBeTruthy(); - expect(plugin.canvas).toBeTruthy(); expect(plugin.addonOptions).toEqual({ selectActiveCell: false }); expect(registerSpy).toHaveBeenCalledWith(plugin.cellRangeSelector); }); @@ -134,7 +146,6 @@ describe('CellSelectionModel Plugin', () => { plugin.init(gridStub); expect(plugin.cellRangeSelector).toBeTruthy(); - expect(plugin.canvas).toBeTruthy(); expect(plugin.addonOptions).toEqual({ selectActiveCell: true, cellRangeSelector: mockCellRangeSelector }); expect(registerSpy).toHaveBeenCalledWith(plugin.cellRangeSelector); }); @@ -153,7 +164,6 @@ describe('CellSelectionModel Plugin', () => { plugin.refreshSelections(); expect(plugin.cellRangeSelector).toBeTruthy(); - expect(plugin.canvas).toBeTruthy(); expect(registerSpy).toHaveBeenCalledWith(plugin.cellRangeSelector); expect(setSelectedRangesSpy).toHaveBeenCalledWith([ { fromCell: 1, fromRow: 2, toCell: 3, toRow: 4 }, @@ -229,6 +239,9 @@ describe('CellSelectionModel Plugin', () => { }); it('should call "setSelectedRanges" with Slick Range with a Right direction when triggered by "onKeyDown" with key combo of Shift+ArrowRight', () => { + // let's test this one without a DataView (aka SlickGrid only) + jest.spyOn(gridStub, 'getData').mockReturnValueOnce([]); + jest.spyOn(gridStub, 'getDataLength').mockReturnValueOnce(NB_ITEMS); jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: 3 }); plugin.init(gridStub); @@ -246,7 +259,7 @@ describe('CellSelectionModel Plugin', () => { }]); }); - it('should call "setSelectedRanges" with Slick Range with a Right direction when triggered by "onKeyDown" with key combo of Shift+ArrowUp', () => { + it('should call "setSelectedRanges" with Slick Range with an Up direction when triggered by "onKeyDown" with key combo of Shift+ArrowUp', () => { jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: 3 }); plugin.init(gridStub); @@ -264,7 +277,7 @@ describe('CellSelectionModel Plugin', () => { }]); }); - it('should call "setSelectedRanges" with Slick Range with a Right direction when triggered by "onKeyDown" with key combo of Shift+ArrowDown', () => { + it('should call "setSelectedRanges" with Slick Range with a Down direction when triggered by "onKeyDown" with key combo of Shift+ArrowDown', () => { jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: 3 }); plugin.init(gridStub); @@ -311,6 +324,168 @@ describe('CellSelectionModel Plugin', () => { expect(onSelectedRangeSpy).toHaveBeenCalledWith(expectedRangeCalled, expect.objectContaining({ detail: { caller: 'SlickCellSelectionModel.setSelectedRanges' } })); }); + it('should call "setSelectedRanges" with Slick Range from current position to a calculated size of a page down when using Shift+PageDown key combo when triggered by "onKeyDown"', () => { + const notifyingRowNumber = 3; + jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: notifyingRowNumber }); + jest.spyOn(gridStub, 'canCellBeSelected').mockReturnValue(true); + const scrollCellSpy = jest.spyOn(gridStub, 'scrollCellIntoView'); + + plugin.init(gridStub); + plugin.resetPageRowCount(); + plugin.setSelectedRanges([ + { fromCell: 1, fromRow: 2, toCell: 3, toRow: 4, contains: () => false }, + { fromCell: 2, fromRow: notifyingRowNumber, toCell: 3, toRow: 4, contains: () => false } + ] as unknown as SlickRange[]); + const setSelectRangeSpy = jest.spyOn(plugin, 'setSelectedRanges'); + const keyDownEvent = addVanillaEventPropagation(new Event('keydown'), 'shiftKey', 'PageDown'); + gridStub.onKeyDown.notify({ cell: 2, row: 3, grid: gridStub }, keyDownEvent, gridStub); + + const expectedRangeCalled = [ + { fromCell: 1, fromRow: 2, toCell: 3, toRow: 4, contains: expect.toBeFunction(), } as unknown as SlickRange, + { + fromCell: 2, fromRow: 3, toCell: 2, toRow: (notifyingRowNumber + CALCULATED_PAGE_ROW_COUNT), + contains: expect.toBeFunction(), toString: expect.toBeFunction(), isSingleCell: expect.toBeFunction(), isSingleRow: expect.toBeFunction(), + }, + ]; + expect(setSelectRangeSpy).toHaveBeenCalledWith(expectedRangeCalled); + expect(scrollCellSpy).toHaveBeenCalledWith((notifyingRowNumber + CALCULATED_PAGE_ROW_COUNT), 2, false); + }); + + it('should call "setSelectedRanges" with Slick Range from current position to the last row index when using Shift+PageDown key combo but there is less rows than an actual page left to display', () => { + const notifyingRowNumber = NB_ITEMS - 10; // will be less than a page size (row count) + const lastRowIndex = NB_ITEMS - 1; + jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: notifyingRowNumber }); + jest.spyOn(gridStub, 'canCellBeSelected').mockReturnValue(true); + const scrollCellSpy = jest.spyOn(gridStub, 'scrollCellIntoView'); + + plugin.init(gridStub); + plugin.setSelectedRanges([ + { fromCell: 1, fromRow: 2, toCell: 3, toRow: 4, contains: () => false }, + { fromCell: 2, fromRow: notifyingRowNumber, toCell: 3, toRow: 4, contains: () => false } + ] as unknown as SlickRange[]); + const setSelectRangeSpy = jest.spyOn(plugin, 'setSelectedRanges'); + const keyDownEvent = addVanillaEventPropagation(new Event('keydown'), 'shiftKey', 'PageDown'); + gridStub.onKeyDown.notify({ cell: 2, row: 3, grid: gridStub }, keyDownEvent, gridStub); + + const expectedRangeCalled = [ + { fromCell: 1, fromRow: 2, toCell: 3, toRow: 4, contains: expect.toBeFunction(), } as unknown as SlickRange, + { + fromCell: 2, fromRow: notifyingRowNumber, toCell: 2, toRow: lastRowIndex, + contains: expect.toBeFunction(), toString: expect.toBeFunction(), isSingleCell: expect.toBeFunction(), isSingleRow: expect.toBeFunction(), + }, + ]; + expect(setSelectRangeSpy).toHaveBeenCalledWith(expectedRangeCalled); + expect(scrollCellSpy).toHaveBeenCalledWith(lastRowIndex, 2, false); + }); + + it('should call "setSelectedRanges" with Slick Range from current position to a calculated size of a page up when using Shift+PageUp key combo when triggered by "onKeyDown"', () => { + const notifyingRowNumber = 100; + const CALCULATED_PAGE_ROW_COUNT = 23; + jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: notifyingRowNumber }); + jest.spyOn(gridStub, 'canCellBeSelected').mockReturnValue(true); + const scrollCellSpy = jest.spyOn(gridStub, 'scrollCellIntoView'); + + plugin.init(gridStub); + plugin.setSelectedRanges([ + { fromCell: 1, fromRow: 99, toCell: 3, toRow: 120, contains: () => false }, + { fromCell: 2, fromRow: notifyingRowNumber, toCell: 3, toRow: 120, contains: () => false } + ] as unknown as SlickRange[]); + const setSelectRangeSpy = jest.spyOn(plugin, 'setSelectedRanges'); + const keyDownEvent = addVanillaEventPropagation(new Event('keydown'), 'shiftKey', 'PageUp'); + gridStub.onKeyDown.notify({ cell: 2, row: 101, grid: gridStub }, keyDownEvent, gridStub); + + const expectedRangeCalled = [ + { fromCell: 1, fromRow: 99, toCell: 3, toRow: 120, contains: expect.toBeFunction(), } as unknown as SlickRange, + { + fromCell: 2, fromRow: (notifyingRowNumber - CALCULATED_PAGE_ROW_COUNT), toCell: 2, toRow: 100, + contains: expect.toBeFunction(), toString: expect.toBeFunction(), isSingleCell: expect.toBeFunction(), isSingleRow: expect.toBeFunction(), + }, + ]; + expect(setSelectRangeSpy).toHaveBeenCalledWith(expectedRangeCalled); + expect(scrollCellSpy).toHaveBeenCalledWith((notifyingRowNumber - CALCULATED_PAGE_ROW_COUNT), 2, false); + }); + + it('should call "setSelectedRanges" with Slick Range from current position to the first row index when using Shift+PageUp key combo but there is less rows than an actual page left to display', () => { + const notifyingRowNumber = 10; // will be less than a page size (row count) + const firstRowIndex = 0; + jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: notifyingRowNumber }); + jest.spyOn(gridStub, 'canCellBeSelected').mockReturnValue(true); + const scrollCellSpy = jest.spyOn(gridStub, 'scrollCellIntoView'); + + plugin.init(gridStub); + plugin.setSelectedRanges([ + { fromCell: 1, fromRow: 2, toCell: 3, toRow: 4, contains: () => false }, + { fromCell: 2, fromRow: notifyingRowNumber, toCell: 3, toRow: 4, contains: () => false } + ] as unknown as SlickRange[]); + const setSelectRangeSpy = jest.spyOn(plugin, 'setSelectedRanges'); + const keyDownEvent = addVanillaEventPropagation(new Event('keydown'), 'shiftKey', 'PageUp'); + gridStub.onKeyDown.notify({ cell: 2, row: 3, grid: gridStub }, keyDownEvent, gridStub); + + const expectedRangeCalled = [ + { fromCell: 1, fromRow: 2, toCell: 3, toRow: 4, contains: expect.toBeFunction(), } as unknown as SlickRange, + { + fromCell: 2, fromRow: firstRowIndex, toCell: 2, toRow: notifyingRowNumber, + contains: expect.toBeFunction(), toString: expect.toBeFunction(), isSingleCell: expect.toBeFunction(), isSingleRow: expect.toBeFunction(), + }, + ]; + expect(setSelectRangeSpy).toHaveBeenCalledWith(expectedRangeCalled); + expect(scrollCellSpy).toHaveBeenCalledWith(firstRowIndex, 2, false); + }); + + it('should call "setSelectedRanges" with Slick Range from current position to row index 0 when using Shift+Home key combo when triggered by "onKeyDown"', () => { + const notifyingRowNumber = 100; + const expectedRowZeroIdx = 0; + jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: notifyingRowNumber }); + jest.spyOn(gridStub, 'canCellBeSelected').mockReturnValue(true); + const scrollCellSpy = jest.spyOn(gridStub, 'scrollCellIntoView'); + + plugin.init(gridStub); + plugin.setSelectedRanges([ + { fromCell: 1, fromRow: 99, toCell: 3, toRow: 120, contains: () => false }, + { fromCell: 2, fromRow: notifyingRowNumber, toCell: 3, toRow: 120, contains: () => false } + ] as unknown as SlickRange[]); + const setSelectRangeSpy = jest.spyOn(plugin, 'setSelectedRanges'); + const keyDownEvent = addVanillaEventPropagation(new Event('keydown'), 'shiftKey', 'Home'); + gridStub.onKeyDown.notify({ cell: 2, row: 101, grid: gridStub }, keyDownEvent, gridStub); + + const expectedRangeCalled = [ + { fromCell: 1, fromRow: 99, toCell: 3, toRow: 120, contains: expect.toBeFunction(), } as unknown as SlickRange, + { + fromCell: 2, fromRow: expectedRowZeroIdx, toCell: 2, toRow: 100, + contains: expect.toBeFunction(), toString: expect.toBeFunction(), isSingleCell: expect.toBeFunction(), isSingleRow: expect.toBeFunction(), + }, + ]; + expect(setSelectRangeSpy).toHaveBeenCalledWith(expectedRangeCalled); + expect(scrollCellSpy).toHaveBeenCalledWith(expectedRowZeroIdx, 2, false); + }); + + it('should call "setSelectedRanges" with Slick Range from current position to last row index when using Shift+End key combo when triggered by "onKeyDown"', () => { + const notifyingRowNumber = 100; + const expectedLastRowIdx = NB_ITEMS - 1; + jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: notifyingRowNumber }); + jest.spyOn(gridStub, 'canCellBeSelected').mockReturnValue(true); + const scrollCellSpy = jest.spyOn(gridStub, 'scrollCellIntoView'); + + plugin.init(gridStub); + plugin.setSelectedRanges([ + { fromCell: 1, fromRow: 99, toCell: 3, toRow: 120, contains: () => false }, + { fromCell: 2, fromRow: notifyingRowNumber, toCell: 3, toRow: 120, contains: () => false } + ] as unknown as SlickRange[]); + const setSelectRangeSpy = jest.spyOn(plugin, 'setSelectedRanges'); + const keyDownEvent = addVanillaEventPropagation(new Event('keydown'), 'shiftKey', 'End'); + gridStub.onKeyDown.notify({ cell: 2, row: 101, grid: gridStub }, keyDownEvent, gridStub); + + const expectedRangeCalled = [ + { fromCell: 1, fromRow: 99, toCell: 3, toRow: 120, contains: expect.toBeFunction(), } as unknown as SlickRange, + { + fromCell: 2, fromRow: notifyingRowNumber, toCell: 2, toRow: expectedLastRowIdx, + contains: expect.toBeFunction(), toString: expect.toBeFunction(), isSingleCell: expect.toBeFunction(), isSingleRow: expect.toBeFunction(), + }, + ]; + expect(setSelectRangeSpy).toHaveBeenCalledWith(expectedRangeCalled); + expect(scrollCellSpy).toHaveBeenCalledWith(expectedLastRowIdx, 2, false); + }); + it('should call "rangesAreEqual" and expect True when both ranges are equal', () => { jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 2, row: 3 }); diff --git a/packages/common/src/extensions/slickCellSelectionModel.ts b/packages/common/src/extensions/slickCellSelectionModel.ts index 53ee481ad..96ef8e41d 100644 --- a/packages/common/src/extensions/slickCellSelectionModel.ts +++ b/packages/common/src/extensions/slickCellSelectionModel.ts @@ -1,5 +1,4 @@ -import { KeyCode } from '../enums/index'; -import type { CellRange, OnActiveCellChangedEventArgs, SlickEventHandler, SlickGrid, SlickNamespace, SlickRange, } from '../interfaces/index'; +import type { CellRange, OnActiveCellChangedEventArgs, SlickDataView, SlickEventHandler, SlickGrid, SlickNamespace, SlickRange } from '../interfaces/index'; import { SlickCellRangeSelector } from './index'; // using external SlickGrid JS libraries @@ -12,9 +11,12 @@ export interface CellSelectionModelOption { export class SlickCellSelectionModel { protected _addonOptions?: CellSelectionModelOption; - protected _canvas: HTMLElement | null = null; + protected _cachedPageRowCount = 0; protected _eventHandler: SlickEventHandler; + protected _dataView?: SlickDataView; protected _grid!: SlickGrid; + protected _prevSelectedRow?: number; + protected _prevKeyDown = ''; protected _ranges: CellRange[] = []; protected _selector: SlickCellRangeSelector; protected _defaults = { @@ -37,10 +39,6 @@ export class SlickCellSelectionModel { return this._addonOptions; } - get canvas() { - return this._canvas; - } - get cellRangeSelector() { return this._selector; } @@ -52,6 +50,9 @@ export class SlickCellSelectionModel { init(grid: SlickGrid) { this._grid = grid; + if (this.hasDataView()) { + this._dataView = grid?.getData() ?? {} as SlickDataView; + } this._addonOptions = { ...this._defaults, ...this._addonOptions } as CellSelectionModelOption; this._eventHandler .subscribe(this._grid.onActiveCellChanged, this.handleActiveCellChange.bind(this) as EventListener) @@ -61,7 +62,6 @@ export class SlickCellSelectionModel { // register the cell range selector plugin grid.registerPlugin(this._selector); - this._canvas = this._grid.getCanvasNode(); } destroy() { @@ -69,7 +69,6 @@ export class SlickCellSelectionModel { } dispose() { - this._canvas = null; if (this._selector) { this._selector.onBeforeCellRangeSelected.unsubscribe(this.handleBeforeCellRangeSelected.bind(this) as EventListener); this._selector.onCellRangeSelected.unsubscribe(this.handleCellRangeSelected.bind(this) as EventListener); @@ -83,6 +82,23 @@ export class SlickCellSelectionModel { return this._ranges; } + /** + * Get the number of rows displayed in the viewport + * Note that the row count is an approximation because it is a calculated value using this formula (viewport / rowHeight = rowCount), + * the viewport must also be displayed for this calculation to work. + * @return {Number} rowCount + */ + getViewportRowCount() { + const viewportElm = this._grid.getViewportNode(); + const viewportHeight = viewportElm?.clientHeight ?? 0; + const scrollbarHeight = this._grid.getScrollbarDimensions()?.height ?? 0; + return Math.floor((viewportHeight - scrollbarHeight) / this._grid.getOptions().rowHeight!) || 1; + } + + hasDataView() { + return !Array.isArray(this._grid.getData()); + } + rangesAreEqual(range1: CellRange[], range2: CellRange[]) { let areDifferent = (range1.length !== range2.length); if (!areDifferent) { @@ -115,6 +131,11 @@ export class SlickCellSelectionModel { return result; } + /** Provide a way to force a recalculation of page row count (for example on grid resize) */ + resetPageRowCount() { + this._cachedPageRowCount = 0; + } + setSelectedRanges(ranges: CellRange[], caller = 'SlickCellSelectionModel.setSelectedRanges') { // simple check for: empty selection didn't change, prevent firing onSelectedRangesChanged if ((!this._ranges || this._ranges.length === 0) && (!ranges || ranges.length === 0)) { @@ -137,6 +158,7 @@ export class SlickCellSelectionModel { // --------------------- protected handleActiveCellChange(_e: Event, args: OnActiveCellChangedEventArgs) { + this._prevSelectedRow = undefined; if (this._addonOptions?.selectActiveCell && args.row !== null && args.cell !== null) { this.setSelectedRanges([new Slick.Range(args.row, args.cell)]); } else if (!this._addonOptions?.selectActiveCell) { @@ -157,18 +179,24 @@ export class SlickCellSelectionModel { this.setSelectedRanges([args.range as SlickRange]); } + protected isKeyAllowed(key: string) { + return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageDown', 'PageUp', 'Home', 'End'].some(k => k === key); + } + protected handleKeyDown(e: KeyboardEvent) { let ranges: CellRange[]; let last: SlickRange; const active = this._grid.getActiveCell(); const metaKey = e.ctrlKey || e.metaKey; - if (active && e.shiftKey && !metaKey && !e.altKey && - (e.which === KeyCode.LEFT || e.key === 'ArrowLeft' - || e.which === KeyCode.RIGHT || e.key === 'ArrowRight' - || e.which === KeyCode.UP || e.key === 'ArrowUp' - || e.which === KeyCode.DOWN || e.key === 'ArrowDown')) { + let dataLn = 0; + if (this._dataView) { + dataLn = this._dataView?.getPagingInfo().pageSize || this._dataView.getLength(); + } else { + dataLn = this._grid.getDataLength(); + } + if (active && e.shiftKey && !metaKey && !e.altKey && this.isKeyAllowed(e.key)) { ranges = this.getSelectedRanges().slice(); if (!ranges.length) { ranges.push(new Slick.Range(active.row, active.cell)); @@ -187,25 +215,65 @@ export class SlickCellSelectionModel { // walking direction const dirRow = active.row === last.fromRow ? 1 : -1; const dirCell = active.cell === last.fromCell ? 1 : -1; - - if (e.which === KeyCode.LEFT || e.key === 'ArrowLeft') { - dCell -= dirCell; - } else if (e.which === KeyCode.RIGHT || e.key === 'ArrowRight') { - dCell += dirCell; - } else if (e.which === KeyCode.UP || e.key === 'ArrowUp') { - dRow -= dirRow; - } else if (e.which === KeyCode.DOWN || e.key === 'ArrowDown') { - dRow += dirRow; + const isSingleKeyMove = e.key.startsWith('Arrow'); + let toRow = 0; + + if (isSingleKeyMove) { + // single cell move: (Arrow{Up/ArrowDown/ArrowLeft/ArrowRight}) + if (e.key === 'ArrowLeft') { + dCell -= dirCell; + } else if (e.key === 'ArrowRight') { + dCell += dirCell; + } else if (e.key === 'ArrowUp') { + dRow -= dirRow; + } else if (e.key === 'ArrowDown') { + dRow += dirRow; + } + toRow = active.row + dirRow * dRow; + } else { + // multiple cell moves: (Home, End, Page{Up/Down}), we need to know how many rows are displayed on a page + if (this._cachedPageRowCount < 1) { + this._cachedPageRowCount = this.getViewportRowCount(); + } + if (this._prevSelectedRow === undefined) { + this._prevSelectedRow = active.row; + } + + if (e.key === 'Home') { + toRow = 0; + } else if (e.key === 'End') { + toRow = dataLn - 1; + } else if (e.key === 'PageUp') { + if (this._prevSelectedRow >= 0) { + toRow = this._prevSelectedRow - this._cachedPageRowCount; + } + if (toRow < 0) { + toRow = 0; + } + } else if (e.key === 'PageDown') { + if (this._prevSelectedRow <= dataLn - 1) { + toRow = this._prevSelectedRow + this._cachedPageRowCount; + } + if (toRow > dataLn - 1) { + toRow = dataLn - 1; + } + } + this._prevSelectedRow = toRow; } // define new selection range - const newLast = new Slick.Range(active.row, active.cell, active.row + dirRow * dRow, active.cell + dirCell * dCell); + const newLast = new Slick.Range(active.row, active.cell, toRow, active.cell + dirCell * dCell); if (this.removeInvalidRanges([newLast]).length) { ranges.push(newLast); const viewRow = dirRow > 0 ? newLast.toRow : newLast.fromRow; const viewCell = dirCell > 0 ? newLast.toCell : newLast.fromCell; - this._grid.scrollRowIntoView(viewRow); - this._grid.scrollCellIntoView(viewRow, viewCell, false); + if (isSingleKeyMove) { + this._grid.scrollRowIntoView(viewRow); + this._grid.scrollCellIntoView(viewRow, viewCell, false); + } else { + this._grid.scrollRowIntoView(toRow); + this._grid.scrollCellIntoView(toRow, viewCell, false); + } } else { ranges.push(last); } @@ -213,6 +281,7 @@ export class SlickCellSelectionModel { e.preventDefault(); e.stopPropagation(); + this._prevKeyDown = e.key; } } } diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index 6560fc4ad..66aacffb9 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -320,6 +320,7 @@ $slick-column-picker-title-width: calc(100% - #{$slick $slick-column-picker-z-index: 9000 !default; /* Grid Menu - hamburger menu */ +$slick-grid-menu-button-display: inline-flex !default; $slick-grid-menu-button-padding: 0 2px !default; $slick-grid-menu-label-margin: 4px !default; $slick-grid-menu-label-font-weight: normal !default; diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index e8d0e9f3d..af6f525b5 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -198,16 +198,17 @@ li.hidden { } .slick-grid-menu-button { - position: absolute; - cursor: pointer; - right: 0; - padding: var(--slick-grid-menu-button-padding, $slick-grid-menu-button-padding); - margin-top: var(--slick-grid-menu-icon-top-margin, $slick-grid-menu-icon-top-margin); background-color: transparent; border: 0; + cursor: pointer; + right: 0; + position: absolute; width: 22px; - font-size: var(--slick-grid-menu-icon-font-size, $slick-grid-menu-icon-font-size); z-index: 2; + display: var(--slick-grid-menu-button-display, $slick-grid-menu-button-display); + font-size: var(--slick-grid-menu-icon-font-size, $slick-grid-menu-icon-font-size); + padding: var(--slick-grid-menu-button-padding, $slick-grid-menu-button-padding); + margin-top: var(--slick-grid-menu-icon-top-margin, $slick-grid-menu-icon-top-margin); } .slick-grid-menu-list { diff --git a/test/cypress/e2e/example19.cy.ts b/test/cypress/e2e/example19.cy.ts new file mode 100644 index 000000000..2cecc0f7d --- /dev/null +++ b/test/cypress/e2e/example19.cy.ts @@ -0,0 +1,147 @@ +describe('Example 19 - ExcelCopyBuffer with Cell Selection', { retries: 0 }, () => { + const titles = [ + '', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', + 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', + 'Z', 'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK' + ]; + const GRID_ROW_HEIGHT = 30; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example19`); + cy.get('h3').should('contain', 'Example 19 - ExcelCopyBuffer with Cell Selection'); + }); + + it('should have exact column titles on 1st grid', () => { + cy.get('.grid19') + .find('.slick-header-columns') + .children() + .each(($child, index) => { + if (index < titles.length) { + expect($child.text()).to.eq(titles[index]); + } + }); + }); + + describe('with Pagination of size 20', () => { + it('should click on cell B14 then Shift+End w/selection B14-19', () => { + cy.getCell(14, 2, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_B14') + .click(); + + cy.get('@cell_B14') + .type('{shift}{end}'); + + cy.get('#selectionRange') + .should('have.text', '{"fromRow":14,"fromCell":2,"toRow":19,"toCell":2}'); + }); + + it('should click on cell C19 then Shift+End w/selection C0-19', () => { + cy.getCell(19, 2, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_C19') + .click(); + + cy.get('@cell_C19') + .type('{shift}{home}'); + + cy.get('#selectionRange') + .should('have.text', '{"fromRow":0,"fromCell":2,"toRow":19,"toCell":2}'); + }); + + it('should click on cell E3 then Shift+PageDown multiple times with current page selection starting at E3 w/selection E3-19', () => { + cy.getCell(3, 5, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_E3') + .click(); + + cy.get('@cell_E3') + .type('{shift}{pagedown}{pagedown}{pagedown}'); + + cy.get('#selectionRange') + .should('have.text', '{"fromRow":3,"fromCell":5,"toRow":19,"toCell":5}'); + }); + + it('should change to 2nd page then click on cell D41 then Shift+PageUp multiple times with current page selection w/selection D25-41', () => { + cy.get('.slick-pagination .icon-seek-next').click(); + + cy.getCell(15, 4, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_D41') + .click(); + + cy.get('@cell_D41') + .type('{shift}{pageup}{pageup}{pageup}'); + + cy.get('#selectionRange') + .should('have.text', '{"fromRow":0,"fromCell":4,"toRow":15,"toCell":4}'); + }); + }); + + describe('no Pagination - showing all', () => { + it('should hide Pagination', () => { + cy.get('[data-text="toggle-pagination-btn"]') + .click(); + }); + + it('should click on cell B10 and ArrowUp 3 times and ArrowDown 1 time and expect cell selection B8-B10', () => { + cy.getCell(10, 2, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_B10') + .click(); + + cy.get('@cell_B10') + .type('{shift}{uparrow}{uparrow}{uparrow}{downarrow}'); + + cy.get('.slick-cell.l2.r2.selected') + .should('have.length', 3); + + cy.get('#selectionRange') + .should('have.text', '{"fromRow":8,"fromCell":2,"toRow":10,"toCell":2}'); + }); + + it('should click on cell D10 then PageDown 2 times w/selection D10-D50 (or D10-D52)', () => { + // 52 is because of a page row count found to be 21 for current browser resolution set in Cypress => 21*2+10 = 52 + cy.getCell(10, 4, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_D10') + .click(); + + cy.get('@cell_D10') + .type('{shift}{pagedown}{pagedown}'); + + cy.get('#selectionRange') + .should('contains', /{"fromRow":10,"fromCell":4,"toRow":5[0-2],"toCell":4}/); + }); + + it('should click on cell D10 then PageDown 3 times then PageUp 1 time w/selection D10-D50 (or D10-D52)', () => { + cy.getCell(10, 4, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_D10') + .click(); + + cy.get('@cell_D10') + .type('{shift}{pagedown}{pagedown}{pagedown}{pageup}'); + + cy.get('#selectionRange') + .should('contains', /{"fromRow":10,"fromCell":4,"toRow":5[0-2],"toCell":4}/); + }); + + it('should click on cell E12 then End key w/selection E52-E99', () => { + cy.getCell(52, 5, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_E52') + .click(); + + cy.get('@cell_E52') + .type('{shift}{end}'); + + cy.get('#selectionRange') + .should('have.text', '{"fromRow":52,"fromCell":5,"toRow":99,"toCell":5}'); + }); + + it('should click on cell C85 then End key w/selection C0-C85', () => { + cy.getCell(85, 3, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }) + .as('cell_C85') + .click(); + + cy.get('@cell_C85') + .type('{shift}{home}'); + + cy.get('#selectionRange') + .should('have.text', '{"fromRow":0,"fromCell":3,"toRow":85,"toCell":3}'); + }); + }); +});