From 35e8b23b900e1149309cc8727e141562684eab1e Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 3 Mar 2026 21:24:05 -0500 Subject: [PATCH] fix(a11y): Tabbing on filter elements should work with Frozen Grid --- .../src/core/__tests__/slickGrid.spec.ts | 139 ++++++++++++++++-- packages/common/src/core/slickGrid.ts | 47 ++++-- 2 files changed, 165 insertions(+), 21 deletions(-) diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 6c194b273..f88b923b8 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -2886,9 +2886,9 @@ describe('SlickGrid core file', () => { grid = new SlickGrid(container, items, columns, defaultOptions); expect(grid).toBeTruthy(); - expect(grid._container.getAttribute('role')).toBe('grid'); - expect(grid._container.getAttribute('aria-colcount')).toBe('1'); - expect(grid._container.getAttribute('aria-rowcount')).toBe('11'); + expect(grid.getContainerNode().getAttribute('role')).toBe('grid'); + expect(grid.getContainerNode().getAttribute('aria-colcount')).toBe('1'); + expect(grid.getContainerNode().getAttribute('aria-rowcount')).toBe('11'); }); it('should return undefined editor when getDataItem() did not find any associated cell item', () => { @@ -5931,15 +5931,16 @@ describe('SlickGrid core file', () => { expect(secondItemAgeCell.classList.contains('to-be-deleted-highlight')).toBeFalsy(); }); - it('should call handleContainerKeyDown and handleGridKeyDown a11y/keyboard coverage', () => { + it('should trigger focusGridCell() when user typed Tab and filter element equals the ancestor header row an', () => { // Use a real DOM structure to avoid infinite recursion const testGridInstance = new TestGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, autoEditByKeypress: true }); - const headerRowCol = document.createElement('div'); - headerRowCol.className = 'slick-headerrow-column'; - headerRowCol.tabIndex = 0; + const focusGridCellSpy = vi.spyOn(testGridInstance, 'focusGridCell'); + const headerRowCol = createDomElement('div', { className: 'slick-headerrow-column' }); + const inputFilterElm = createDomElement('input', { className: 'slick-filter-input', tabIndex: 0 }); + headerRowCol.appendChild(inputFilterElm); container.appendChild(headerRowCol); const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' }) as any; - Object.defineProperty(tabEvent, 'target', { value: headerRowCol }); + Object.defineProperty(tabEvent, 'target', { value: inputFilterElm }); testGridInstance.callHandleContainerKeyDown(tabEvent); const keyEvent = { @@ -5951,9 +5952,129 @@ describe('SlickGrid core file', () => { preventDefault: vi.fn(), } as any; testGridInstance.setActiveCell(0, 1); - testGridInstance.setCurrentEditorNull(); testGridInstance.callHandleGridKeyDown(keyEvent); skipGridDestroy = true; + + expect(focusGridCellSpy).toHaveBeenCalled(); + }); + + it('should trigger focusGridMenu() when user typed Shift+Tab and filter element equals the ancestor header row an', () => { + // 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 headerRowCol = createDomElement('div', { className: 'slick-headerrow-column' }); + const inputFilterElm = createDomElement('input', { className: 'slick-filter-input', tabIndex: 0 }); + headerRowCol.appendChild(inputFilterElm); + container.appendChild(headerRowCol); + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }) as any; + Object.defineProperty(tabEvent, 'target', { value: inputFilterElm }); + 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(focusGridMenuSpy).toHaveBeenCalled(); + }); + + it('should focus on right pane first filter 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 focusGridMenuSpy = vi.spyOn(testGridInstance, 'focusGridMenu'); + const headerRowCol1 = createDomElement('div', { className: 'slick-headerrow-column' }); + const headerRowCol2 = createDomElement('div', { className: 'slick-headerrow-column' }); + const headerRowCol3 = createDomElement('div', { className: 'slick-headerrow-column' }); + const headerRowCol4 = createDomElement('div', { className: 'slick-headerrow-column' }); + const inputFilter1 = createDomElement('input', { className: 'slick-filter-input filter1', tabIndex: 0 }); + 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(inputFilter3, '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); + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false }) as any; + Object.defineProperty(tabEvent, 'target', { value: inputFilter2 }); + 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(filter3Spy).toHaveBeenCalled(); + }); + + it('should focus on right pane first filter 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 focusGridMenuSpy = vi.spyOn(testGridInstance, 'focusGridMenu'); + const headerRowCol1 = createDomElement('div', { className: 'slick-headerrow-column' }); + const headerRowCol2 = createDomElement('div', { className: 'slick-headerrow-column' }); + const headerRowCol3 = createDomElement('div', { className: 'slick-headerrow-column' }); + const headerRowCol4 = createDomElement('div', { className: 'slick-headerrow-column' }); + const inputFilter1 = createDomElement('input', { className: 'slick-filter-input filter1', tabIndex: 0 }); + 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'); + 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); + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }) as any; + Object.defineProperty(tabEvent, 'target', { value: inputFilter3 }); + 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(filter3Spy).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 6bf35eebd..544b5bcc9 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -5766,8 +5766,10 @@ export class SlickGrid = Column, O e focusHeaderRowFilter(focusOnLast = false): boolean { const headerRow = this.getHeaderRow(); if (this._options.showHeaderRow && headerRow) { - const firstHeaderRow = headerRow instanceof HTMLElement ? headerRow : headerRow[0]; - const allFilterElms = firstHeaderRow?.querySelectorAll('.slick-headerrow-column [tabIndex="0"]') || []; + const headerRows = headerRow instanceof HTMLElement ? [headerRow] : [...headerRow]; + const allFilterElms = headerRows.flatMap((row) => + Array.from(row.querySelectorAll('.slick-headerrow-column *[tabIndex="0"]')) + ); const filterLn = allFilterElms.length; let closestVisibleFilter: HTMLElement | null = null; if (filterLn > 0) { @@ -5871,18 +5873,39 @@ export class SlickGrid = Column, O e protected handleContainerKeyDown(e: KeyboardEvent & { originalEvent: Event }): void { if (e.key === 'Tab' && !e.ctrlKey && !e.altKey) { - let headerRowSelector = '.slick-headerrow-column *[tabIndex="0"]'; - const ancestorHeaderRow = (e.target as HTMLElement)?.closest(headerRowSelector); - if (!e.shiftKey) { - // when using Tab, find last header row filter - headerRowSelector = '.slick-headerrow-column:last-child *[tabIndex="0"]'; + 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 (allFilterElms.length > 0) { + const targetFilterElm = e.shiftKey ? allFilterElms[0] : allFilterElms[allFilterElms.length - 1]; + + if (targetFilterElm === ancestorHeaderRow) { + // focus grid menu when Shift+Tab OR focus on first cell when using Tab + this.stopFullBubbling(e); + e.shiftKey ? this.focusGridMenu() : this.focusGridCell(); + } else if (this._options.frozenColumn! >= 0) { + // when using frozen columns + const lastLeftFilterElm = allLeftFilterElms[allLeftFilterElms.length - 1]; + if (e.shiftKey && e.target === allRightFilterElms[0]) { + // using Shift+Tab and we're on the first filter element of the right pane, let's focus on last element of the left pane + this.focusElementWithoutBubbling(e, lastLeftFilterElm); + } else if (!e.shiftKey && e.target === lastLeftFilterElm) { + // using frozen columns and we're on the last filter element on the left pane, let's focus on the first element of the right pane + this.focusElementWithoutBubbling(e, allRightFilterElms[0]); + } + } } + } + } - if (this._container.querySelector(headerRowSelector) === ancestorHeaderRow) { - this.stopFullBubbling(e); - // focus grid menu when Shift+Tab or focus on first cell when using Tab - e.shiftKey ? this.focusGridMenu() : this.focusGridCell(); - } + // focus element and stop event bubbling (for keyboard events) + protected focusElementWithoutBubbling(e: KeyboardEvent, target: Element | null): void { + if (target) { + (target as HTMLElement).focus(); + this.stopFullBubbling(e); } }