From 89c455228a8a49cbe0505d6a72209144ad5e1b16 Mon Sep 17 00:00:00 2001 From: "hjaewonnn@hanmail.net" Date: Thu, 30 Jul 2026 12:53:11 +0900 Subject: [PATCH] fix(a11y): add missing ARIA row/rowgroup structure to grid headers The grid container gets `role="grid"` and header cells get `role="columnheader"`, but the container levels in between (`.slick-header-container > .slick-header > .slick-header-columns`) carry no role. Elements without a role and without focusability are transparent in the accessibility tree, so axe reports two violations on every grid: - `aria-required-parent` (one per column): `role="columnheader"` has no `role="row"` parent - `aria-required-children`: `role="grid"` is computed to directly own `[role=columnheader]` and `div[tabindex]` (the two 0x0 focus sinks), neither allowed as a child of `grid` Nothing is visually broken; the impact is that screen readers do not get the table structure. Roles added at element-creation time: - `.slick-header` (header scroller, L/R) -> rowgroup - `.slick-header-columns` (L/R) -> row - `.slick-headerrow` (filter row scroller, L/R) -> rowgroup - `.slick-headerrow-columns` (L/R) -> row - `.slick-headerrow-column` (filter row cell) -> gridcell - focus sinks (0x0, tabindex=-1) -> aria-hidden="true" The filter row is fixed as a unit on purpose: giving `.slick-headerrow-columns` a `row` role without giving its cells a `gridcell` role would introduce a *new* aria-required-children violation on grids that show the filter row. `aria-colcount` / `aria-rowcount` are already handled by updateRowCount(), so untouched. Co-Authored-By: Claude Opus 5 --- .../src/core/__tests__/slickGrid.spec.ts | 27 +++++++++++++++++++ packages/common/src/core/slickGrid.ts | 26 +++++++++++------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 3c940fa8e..a5042e638 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -3101,6 +3101,33 @@ describe('SlickGrid core file', () => { expect(grid.getContainerNode().getAttribute('aria-rowcount')).toBe('11'); }); + it('should expect the header structure to have the ARIA roles required between grid and columnheader', () => { + grid = new SlickGrid(container, items, columns, { ...defaultOptions, showHeaderRow: true }); + + const headerColumnElm = container.querySelector('[role="columnheader"]') as HTMLDivElement; + expect(headerColumnElm).toBeTruthy(); + // a columnheader requires a "row" parent, and that row requires a "rowgroup" (or grid) parent, + // otherwise axe reports `aria-required-parent` + `aria-required-children` on every grid + expect(headerColumnElm.parentElement!.classList.contains('slick-header-columns')).toBe(true); + expect(headerColumnElm.parentElement!.getAttribute('role')).toBe('row'); + expect(headerColumnElm.parentElement!.parentElement!.getAttribute('role')).toBe('rowgroup'); + + // same skeleton for the header row (filter row): rowgroup > row > gridcell + const headerRowCellElm = container.querySelector('.slick-headerrow-column') as HTMLDivElement; + expect(headerRowCellElm.getAttribute('role')).toBe('gridcell'); + expect(headerRowCellElm.parentElement!.getAttribute('role')).toBe('row'); + expect(headerRowCellElm.parentElement!.parentElement!.getAttribute('role')).toBe('rowgroup'); + }); + + it('should expect the focus sinks to be hidden from the a11y tree', () => { + grid = new SlickGrid(container, items, columns, defaultOptions); + + // 0x0 helper divs with tabindex=-1 are otherwise computed as unallowed children of role="grid" + const sinks = container.querySelectorAll('div[tabindex="-1"]:not([class])'); + expect(sinks.length).toBe(2); + sinks.forEach((sink) => expect(sink.getAttribute('aria-hidden')).toBe('true')); + }); + it('should return undefined editor when getDataItem() did not find any associated cell item', () => { const columns = [ { id: 'name', field: 'name', name: 'Name' }, diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 46c31047d..5f0675952 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -698,6 +698,10 @@ export class SlickGrid = Column, O e this._container ); + // 0x0 helper div, programmatically focusable only (tabindex=-1). Exposing it to the a11y tree makes it + // an unallowed child of role="grid" (axe `aria-required-children`), so keep it out of the tree. + this._focusSink.setAttribute('aria-hidden', 'true'); + if (this._options.createTopHeaderPanel) { this._topHeaderPanelScroller = createDomElement( 'div', @@ -761,8 +765,10 @@ export class SlickGrid = Column, O e // Append the header scroller containers const headerContainerL = createDomElement('div', { className: 'slick-header-container' }, this._paneHeaderL); const headerContainerR = createDomElement('div', { className: 'slick-header-container' }, this._paneHeaderR); - 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); + // prettier-ignore + this._headerScrollerL = createDomElement('div', { className: 'slick-header slick-state-default slick-header-left', role: 'rowgroup' }, headerContainerL); + // prettier-ignore + this._headerScrollerR = createDomElement('div', { className: 'slick-header slick-state-default slick-header-right', role: 'rowgroup' }, 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 @@ -777,20 +783,22 @@ export class SlickGrid = Column, O e // Append the columnn containers to the headers this._headerL = createDomElement( 'div', - { className: 'slick-header-columns slick-header-columns-left', style: { left: '-1000px' } }, + { className: 'slick-header-columns slick-header-columns-left', style: { left: '-1000px' }, role: 'row' }, this._headerScrollerL ); this._headerR = createDomElement( 'div', - { className: 'slick-header-columns slick-header-columns-right', style: { left: '-1000px' } }, + { className: 'slick-header-columns slick-header-columns-right', style: { left: '-1000px' }, role: 'row' }, this._headerScrollerR ); // Cache the header columns this._headers = [this._headerL, this._headerR]; - this._headerRowScrollerL = createDomElement('div', { className: 'slick-headerrow slick-state-default' }, this._paneTopL); - this._headerRowScrollerR = createDomElement('div', { className: 'slick-headerrow slick-state-default' }, this._paneTopR); + // prettier-ignore + this._headerRowScrollerL = createDomElement('div', { className: 'slick-headerrow slick-state-default', role: 'rowgroup' }, this._paneTopL); + // prettier-ignore + this._headerRowScrollerR = createDomElement('div', { className: 'slick-headerrow slick-state-default', role: 'rowgroup' }, this._paneTopR); this._headerRowScroller = [this._headerRowScrollerL, this._headerRowScrollerR]; @@ -807,12 +815,12 @@ export class SlickGrid = Column, O e this._headerRowL = createDomElement( 'div', - { className: 'slick-headerrow-columns slick-headerrow-columns-left' }, + { className: 'slick-headerrow-columns slick-headerrow-columns-left', role: 'row' }, this._headerRowScrollerL ); this._headerRowR = createDomElement( 'div', - { className: 'slick-headerrow-columns slick-headerrow-columns-right' }, + { className: 'slick-headerrow-columns slick-headerrow-columns-right', role: 'row' }, this._headerRowScrollerR ); @@ -1957,7 +1965,7 @@ export class SlickGrid = Column, O e if (this._options.showHeaderRow) { const headerRowCell = createDomElement( 'div', - { className: `slick-state-default slick-headerrow-column l${i} r${i}` }, + { className: `slick-state-default slick-headerrow-column l${i} r${i}`, role: 'gridcell' }, headerRowTarget ); const frozenClasses = this.hasFrozenColumns() && i <= this._options.frozenColumn! ? 'frozen' : null;