From fd42beef1a46cb20ec7f6f8086531d54dc358ed7 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 4 Mar 2026 23:03:19 -0500 Subject: [PATCH 1/2] fix(a11y): Tabbing on header columns should work with Frozen Grid --- .../src/core/__tests__/slickGrid.spec.ts | 154 ++++++++++++++++-- packages/common/src/core/slickGrid.ts | 36 ++-- 2 files changed, 163 insertions(+), 27 deletions(-) diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index f88b923b8..00ee32263 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -5935,10 +5935,13 @@ describe('SlickGrid core file', () => { // Use a real DOM structure to avoid infinite recursion const testGridInstance = new TestGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, autoEditByKeypress: true }); const focusGridCellSpy = vi.spyOn(testGridInstance, 'focusGridCell'); + const headerRowLeftCols = createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }); const headerRowCol = createDomElement('div', { className: 'slick-headerrow-column' }); const inputFilterElm = createDomElement('input', { className: 'slick-filter-input', tabIndex: 0 }); headerRowCol.appendChild(inputFilterElm); - container.appendChild(headerRowCol); + headerRowLeftCols.appendChild(headerRowCol); + container.appendChild(headerRowLeftCols); + Object.defineProperty(inputFilterElm, 'offsetParent', { value: 5 }); const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' }) as any; Object.defineProperty(tabEvent, 'target', { value: inputFilterElm }); testGridInstance.callHandleContainerKeyDown(tabEvent); @@ -5962,10 +5965,13 @@ describe('SlickGrid core file', () => { // Use a real DOM structure to avoid infinite recursion const testGridInstance = new TestGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, autoEditByKeypress: true }); const focusGridMenuSpy = vi.spyOn(testGridInstance, 'focusGridMenu'); + const headerRowLeftCols = createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }); const headerRowCol = createDomElement('div', { className: 'slick-headerrow-column' }); const inputFilterElm = createDomElement('input', { className: 'slick-filter-input', tabIndex: 0 }); headerRowCol.appendChild(inputFilterElm); - container.appendChild(headerRowCol); + headerRowLeftCols.appendChild(headerRowCol); + container.appendChild(headerRowLeftCols); + Object.defineProperty(inputFilterElm, 'offsetParent', { value: 5 }); const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }) as any; Object.defineProperty(tabEvent, 'target', { value: inputFilterElm }); testGridInstance.callHandleContainerKeyDown(tabEvent); @@ -5993,7 +5999,8 @@ describe('SlickGrid core file', () => { autoEditByKeypress: true, frozenColumn: 0, }); - const focusGridMenuSpy = vi.spyOn(testGridInstance, 'focusGridMenu'); + const headerRowLeftCols = createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }); + const headerRowRightCols = createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }); const headerRowCol1 = createDomElement('div', { className: 'slick-headerrow-column' }); const headerRowCol2 = createDomElement('div', { className: 'slick-headerrow-column' }); const headerRowCol3 = createDomElement('div', { className: 'slick-headerrow-column' }); @@ -6007,10 +6014,16 @@ describe('SlickGrid core file', () => { headerRowCol2.appendChild(inputFilter2); headerRowCol3.appendChild(inputFilter3); headerRowCol4.appendChild(inputFilter4); - container.querySelector('.slick-pane-left')!.appendChild(headerRowCol1); - container.querySelector('.slick-pane-left')!.appendChild(headerRowCol2); - container.querySelector('.slick-pane-right')!.appendChild(headerRowCol3); - container.querySelector('.slick-pane-right')!.appendChild(headerRowCol4); + headerRowLeftCols.appendChild(headerRowCol1); + headerRowLeftCols.appendChild(headerRowCol2); + headerRowRightCols.appendChild(headerRowCol3); + headerRowRightCols.appendChild(headerRowCol4); + container.querySelector('.slick-pane-left')!.appendChild(headerRowLeftCols); + container.querySelector('.slick-pane-right')!.appendChild(headerRowRightCols); + Object.defineProperty(inputFilter1, 'offsetParent', { value: 5 }); + Object.defineProperty(inputFilter2, 'offsetParent', { value: 5 }); + Object.defineProperty(inputFilter3, 'offsetParent', { value: 5 }); + Object.defineProperty(inputFilter4, 'offsetParent', { value: 5 }); const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false }) as any; Object.defineProperty(tabEvent, 'target', { value: inputFilter2 }); @@ -6039,7 +6052,8 @@ describe('SlickGrid core file', () => { autoEditByKeypress: true, frozenColumn: 0, }); - const focusGridMenuSpy = vi.spyOn(testGridInstance, 'focusGridMenu'); + const headerRowLeftCols = createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-left' }); + const headerRowRightCols = createDomElement('div', { className: 'slick-headerrow-columns slick-headerrow-columns-right' }); const headerRowCol1 = createDomElement('div', { className: 'slick-headerrow-column' }); const headerRowCol2 = createDomElement('div', { className: 'slick-headerrow-column' }); const headerRowCol3 = createDomElement('div', { className: 'slick-headerrow-column' }); @@ -6048,15 +6062,21 @@ describe('SlickGrid core file', () => { const inputFilter2 = createDomElement('input', { className: 'slick-filter-input filter2', tabIndex: 0 }); const inputFilter3 = createDomElement('input', { className: 'slick-filter-input filter3', tabIndex: 0 }); const inputFilter4 = createDomElement('input', { className: 'slick-filter-input filter4', tabIndex: 0 }); - const filter3Spy = vi.spyOn(inputFilter2, 'focus'); + const filter2Spy = vi.spyOn(inputFilter2, 'focus'); headerRowCol1.appendChild(inputFilter1); headerRowCol2.appendChild(inputFilter2); headerRowCol3.appendChild(inputFilter3); headerRowCol4.appendChild(inputFilter4); - container.querySelector('.slick-pane-left')!.appendChild(headerRowCol1); - container.querySelector('.slick-pane-left')!.appendChild(headerRowCol2); - container.querySelector('.slick-pane-right')!.appendChild(headerRowCol3); - container.querySelector('.slick-pane-right')!.appendChild(headerRowCol4); + headerRowLeftCols.appendChild(headerRowCol1); + headerRowLeftCols.appendChild(headerRowCol2); + headerRowRightCols.appendChild(headerRowCol3); + headerRowRightCols.appendChild(headerRowCol4); + container.querySelector('.slick-pane-left')!.appendChild(headerRowLeftCols); + container.querySelector('.slick-pane-right')!.appendChild(headerRowRightCols); + Object.defineProperty(inputFilter1, 'offsetParent', { value: 5 }); + Object.defineProperty(inputFilter2, 'offsetParent', { value: 5 }); + Object.defineProperty(inputFilter3, 'offsetParent', { value: 5 }); + Object.defineProperty(inputFilter4, 'offsetParent', { value: 5 }); const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }) as any; Object.defineProperty(tabEvent, 'target', { value: inputFilter3 }); @@ -6074,7 +6094,113 @@ describe('SlickGrid core file', () => { testGridInstance.callHandleGridKeyDown(keyEvent); skipGridDestroy = true; - expect(filter3Spy).toHaveBeenCalled(); + expect(filter2Spy).toHaveBeenCalled(); + }); + + it('should focus on right pane first column header element when user typed Tab and frozenColumn is set to 0', () => { + // Use a real DOM structure to avoid infinite recursion + const testGridInstance = new TestGrid(container, items, columns, { + ...defaultOptions, + enableCellNavigation: true, + autoEditByKeypress: true, + frozenColumn: 0, + }); + const headerLeftCols = createDomElement('div', { className: 'slick-header-columns slick-header-columns-left' }); + const headerRightCols = createDomElement('div', { className: 'slick-header-columns slick-header-columns-right' }); + const headerCol1 = createDomElement('div', { className: 'slick-header-column', tabIndex: 0 }); + const headerCol2 = createDomElement('div', { className: 'slick-header-column', tabIndex: 0 }); + const headerCol3 = createDomElement('div', { className: 'slick-header-column', tabIndex: 0 }); + const headerCol4 = createDomElement('div', { className: 'slick-header-column', tabIndex: 0 }); + const spanTitle1 = createDomElement('span', { className: 'slick-column-name', textContent: 'Title 1' }); + const spanTitle2 = createDomElement('span', { className: 'slick-column-name', textContent: 'Title 2' }); + const spanTitle3 = createDomElement('span', { className: 'slick-column-name', textContent: 'Title 3' }); + const spanTitle4 = createDomElement('span', { className: 'slick-column-name', textContent: 'Title 4' }); + const title3Spy = vi.spyOn(headerCol3, 'focus'); + headerCol1.appendChild(spanTitle1); + headerCol2.appendChild(spanTitle2); + headerCol3.appendChild(spanTitle3); + headerCol4.appendChild(spanTitle4); + headerLeftCols.appendChild(headerCol1); + headerLeftCols.appendChild(headerCol2); + headerRightCols.appendChild(headerCol3); + headerRightCols.appendChild(headerCol4); + container.querySelector('.slick-pane-left')!.appendChild(headerLeftCols); + container.querySelector('.slick-pane-right')!.appendChild(headerRightCols); + Object.defineProperty(headerCol1, 'offsetParent', { value: 5 }); + Object.defineProperty(headerCol2, 'offsetParent', { value: 5 }); + Object.defineProperty(headerCol3, 'offsetParent', { value: 5 }); + Object.defineProperty(headerCol4, 'offsetParent', { value: 5 }); + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false }) as any; + Object.defineProperty(tabEvent, 'target', { value: headerCol2 }); + testGridInstance.callHandleContainerKeyDown(tabEvent); + + const keyEvent = { + key: 'a', + ctrlKey: false, + originalEvent: {}, + target: container.querySelector('.slick-cell.l1.r1'), + stopPropagation: vi.fn(), + preventDefault: vi.fn(), + } as any; + testGridInstance.setActiveCell(0, 1); + testGridInstance.callHandleGridKeyDown(keyEvent); + skipGridDestroy = true; + + expect(title3Spy).toHaveBeenCalled(); + }); + + it('should focus on right pane first column header element when user typed Shift+Tab and frozenColumn is set to 0', () => { + // Use a real DOM structure to avoid infinite recursion + const testGridInstance = new TestGrid(container, items, columns, { + ...defaultOptions, + enableCellNavigation: true, + autoEditByKeypress: true, + frozenColumn: 0, + }); + const headerLeftCols = createDomElement('div', { className: 'slick-header-columns slick-header-columns-left' }); + const headerRightCols = createDomElement('div', { className: 'slick-header-columns slick-header-columns-right' }); + const headerCol1 = createDomElement('div', { className: 'slick-header-column', tabIndex: 0 }); + const headerCol2 = createDomElement('div', { className: 'slick-header-column', tabIndex: 0 }); + const headerCol3 = createDomElement('div', { className: 'slick-header-column', tabIndex: 0 }); + const headerCol4 = createDomElement('div', { className: 'slick-header-column', tabIndex: 0 }); + const spanTitle1 = createDomElement('span', { className: 'slick-column-name', textContent: 'Title 1' }); + const spanTitle2 = createDomElement('span', { className: 'slick-column-name', textContent: 'Title 2' }); + const spanTitle3 = createDomElement('span', { className: 'slick-column-name', textContent: 'Title 3' }); + const spanTitle4 = createDomElement('span', { className: 'slick-column-name', textContent: 'Title 4' }); + const title2Spy = vi.spyOn(headerCol2, 'focus'); + headerCol1.appendChild(spanTitle1); + headerCol2.appendChild(spanTitle2); + headerCol3.appendChild(spanTitle3); + headerCol4.appendChild(spanTitle4); + headerLeftCols.appendChild(headerCol1); + headerLeftCols.appendChild(headerCol2); + headerRightCols.appendChild(headerCol3); + headerRightCols.appendChild(headerCol4); + container.querySelector('.slick-pane-left')!.appendChild(headerLeftCols); + container.querySelector('.slick-pane-right')!.appendChild(headerRightCols); + Object.defineProperty(headerCol1, 'offsetParent', { value: 5 }); + Object.defineProperty(headerCol2, 'offsetParent', { value: 5 }); + Object.defineProperty(headerCol3, 'offsetParent', { value: 5 }); + Object.defineProperty(headerCol4, 'offsetParent', { value: 5 }); + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }) as any; + Object.defineProperty(tabEvent, 'target', { value: headerCol3 }); + testGridInstance.callHandleContainerKeyDown(tabEvent); + + const keyEvent = { + key: 'a', + ctrlKey: false, + originalEvent: {}, + target: container.querySelector('.slick-cell.l1.r1'), + stopPropagation: vi.fn(), + preventDefault: vi.fn(), + } as any; + testGridInstance.setActiveCell(0, 1); + testGridInstance.callHandleGridKeyDown(keyEvent); + skipGridDestroy = true; + + expect(title2Spy).toHaveBeenCalled(); }); it('should trigger onHeaderKeyDown and sortCallback on Enter/Space', () => { diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 544b5bcc9..14900e405 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -756,6 +756,12 @@ export class SlickGrid = Column, O e this._headerScrollerL = createDomElement('div', { className: 'slick-header slick-state-default slick-header-left' }, headerContainerL); this._headerScrollerR = createDomElement('div', { className: 'slick-header slick-state-default slick-header-right' }, headerContainerR); + // header scroll position could change when using frozen grid and tabbing on next available header + // so we need to make sure that all containers (header, headerrow, toppanel) are all in sync when that happens + this._bindingEventService.bind(this._headerScrollerR, 'scroll', (e) => { + this.scrollToX((e.target as HTMLElement).scrollLeft); + }); + // Cache the header scroller containers this._headerScroller.push(this._headerScrollerL); this._headerScroller.push(this._headerScrollerR); @@ -1057,9 +1063,7 @@ export class SlickGrid = Column, O e this._bindingEventService.bind(this._canvas, 'contextmenu', this.handleContextMenu.bind(this) as EventListener); this._bindingEventService.bind(this._canvas, 'mouseover', this.handleCellMouseOver.bind(this) as EventListener); this._bindingEventService.bind(this._canvas, 'mouseout', this.handleCellMouseOut.bind(this) as EventListener); - if (this._options.enableFiltering) { - this._bindingEventService.bind(this._container, 'keydown', this.handleContainerKeyDown.bind(this) as EventListener); - } + this._bindingEventService.bind(this._container, 'keydown', this.handleContainerKeyDown.bind(this) as EventListener); if (Draggable) { this.slickDraggableInstance = Draggable({ @@ -5749,8 +5753,8 @@ export class SlickGrid = Column, O e * @param index - Column index to focus (defaults to 0) */ focusHeaderMenuOrColumn(index = 0): void { - const headerMenuElm = this.getHeaderColumn(index).querySelector('.slick-header-menu-button[tabIndex="0"]'); - if (headerMenuElm && headerMenuElm.offsetParent !== null) { + const [headerMenuElm] = this.getVisibleElements(this.getHeaderColumn(index), '.slick-header-menu-button[tabIndex="0"]'); + if (headerMenuElm) { headerMenuElm.focus(); } else { this.focusHeaderColumn(index); @@ -5778,7 +5782,7 @@ export class SlickGrid = Column, O e const step = focusOnLast ? -1 : 1; for (let i = start; i !== end; i += step) { const elm = allFilterElms[i]; - if (elm.offsetParent !== null) { + if (elm && elm.offsetParent !== null) { closestVisibleFilter = elm; break; } @@ -5871,18 +5875,24 @@ export class SlickGrid = Column, O e this.triggerEvent(this.onDragEnd, dd, e); } + /** get only visible elemnts from a container and a query selector, e.g. elements with `display: none` will be excluded. */ + protected getVisibleElements(container: HTMLElement, selector: string): HTMLElement[] { + return Array.from(container.querySelectorAll(selector)).filter((el) => el.offsetParent !== null); + } + protected handleContainerKeyDown(e: KeyboardEvent & { originalEvent: Event }): void { - if (e.key === 'Tab' && !e.ctrlKey && !e.altKey) { - const headerRowSelector = '.slick-headerrow-column *[tabIndex="0"]'; - const allFilterElms = this._container.querySelectorAll(headerRowSelector); - const allLeftFilterElms = this._container.querySelectorAll(`.slick-pane-left ${headerRowSelector}`); - const allRightFilterElms = this._container.querySelectorAll(`.slick-pane-right ${headerRowSelector}`); - const ancestorHeaderRow = e.target instanceof HTMLElement ? e.target.closest(headerRowSelector) : null; + if (e.target instanceof HTMLElement && e.key === 'Tab' && !e.ctrlKey && !e.altKey) { + const isInHeaderRow = e.target.closest('.slick-headerrow-columns'); + const headerSelector = `.slick-${isInHeaderRow ? 'headerrow-column' : 'header-columns'} *[tabIndex="0"]`; + const allFilterElms = this.getVisibleElements(this._container, headerSelector); + const allLeftFilterElms = this.getVisibleElements(this._container, `.slick-pane-left ${headerSelector}`); + const allRightFilterElms = this.getVisibleElements(this._container, `.slick-pane-right ${headerSelector}`); + const ancestorHeaderRow = e.target instanceof HTMLElement ? e.target.closest(headerSelector) : null; if (allFilterElms.length > 0) { const targetFilterElm = e.shiftKey ? allFilterElms[0] : allFilterElms[allFilterElms.length - 1]; - if (targetFilterElm === ancestorHeaderRow) { + if (targetFilterElm === ancestorHeaderRow && isInHeaderRow) { // focus grid menu when Shift+Tab OR focus on first cell when using Tab this.stopFullBubbling(e); e.shiftKey ? this.focusGridMenu() : this.focusGridCell(); From 4385612e5f687014363faafbef867caf7dc04ede Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 4 Mar 2026 23:26:15 -0500 Subject: [PATCH 2/2] chore: add missing unit test --- .../src/core/__tests__/slickGrid.spec.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 00ee32263..b48723fce 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -7384,7 +7384,7 @@ describe('SlickGrid core file', () => { }); }); - describe('Header Click', () => { + describe('Header Events', () => { it('should trigger onHeaderClick notify when not column resizing', () => { const columns = [ { id: 'name', field: 'name', name: 'Name' }, @@ -7400,6 +7400,23 @@ describe('SlickGrid core file', () => { expect(onHeaderClickSpy).toHaveBeenCalledWith({ column: columns[0], grid }, expect.anything(), grid); }); + + it('should call scrollToX() when header right is scrolled', () => { + const columns = [ + { id: 'name', field: 'name', name: 'Name' }, + { id: 'age', field: 'age', name: 'Age', editorClass: InputEditor }, + ] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true }); + vi.spyOn(grid, 'getCellFromEvent').mockReturnValue(null); + const scrollToXSpy = vi.spyOn(grid, 'scrollToX'); + const headerColumns = container.querySelectorAll('.slick-header-column'); + const event = new CustomEvent('scroll'); + Object.defineProperty(headerColumns[0], 'scrollLeft', { writable: true, value: 100 }); + Object.defineProperty(event, 'target', { writable: true, value: headerColumns[0] }); + container.querySelector('.slick-header.slick-header-right')!.dispatchEvent(event); + + expect(scrollToXSpy).toHaveBeenCalledWith(100); + }); }); describe('Header Context Menu', () => {