diff --git a/packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts b/packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts index 9bd31ae13cb..22edb7d6060 100644 --- a/packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts +++ b/packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts @@ -11,7 +11,9 @@ import { AgComponentStub, RefPlaceholder, _isComponent, + _removeAriaExpanded, _removeFromParent, + _setAriaControls, _setAriaExpanded, _setAriaRole, _setDisplayed, @@ -412,6 +414,8 @@ export class AgGroupComponent< TPropertiesService, TComponentSelectorType > { + const containerId = `ag-${this.getCompId()}-group-container`; + this.eContainer.id = containerId; const titleBar = this.createManagedBean( new DefaultTitleBar< TBeanCollection, @@ -420,7 +424,7 @@ export class AgGroupComponent< TCommon, TPropertiesService, TComponentSelectorType - >(this.params) + >(this.params, containerId) ); this.eTitleBar = titleBar; titleBar.refreshOnExpand(this.expanded); @@ -471,26 +475,28 @@ const TITLE_BAR_DISABLED_CLASS = 'ag-disabled-group-title-bar'; function getDefaultTitleBarTemplate( params: AgGroupComponentParams ): AgElementParams { - const cssIdentifier = params.cssIdentifier ?? 'default'; + const { cssIdentifier, suppressOpenCloseIcons } = params; + const normalisedCssIdentifier = cssIdentifier ?? 'default'; + const interactiveRole = suppressOpenCloseIcons ? 'group' : 'button'; return { tag: 'div', - cls: `ag-group-title-bar ag-${cssIdentifier}-group-title-bar ag-unselectable`, - role: params.suppressKeyboardNavigation ? 'presentation' : 'group', + cls: `ag-group-title-bar ag-${normalisedCssIdentifier}-group-title-bar ag-unselectable`, + role: params.suppressKeyboardNavigation ? 'presentation' : interactiveRole, children: [ { tag: 'span', ref: 'eGroupOpenedIcon', - cls: `ag-group-title-bar-icon ag-${cssIdentifier}-group-title-bar-icon`, + cls: `ag-group-title-bar-icon ag-${normalisedCssIdentifier}-group-title-bar-icon`, role: 'presentation', }, { tag: 'span', ref: 'eGroupClosedIcon', - cls: `ag-group-title-bar-icon ag-${cssIdentifier}-group-title-bar-icon`, + cls: `ag-group-title-bar-icon ag-${normalisedCssIdentifier}-group-title-bar-icon`, role: 'presentation', }, - { tag: 'span', ref: 'eTitle', cls: `ag-group-title ag-${cssIdentifier}-group-title` }, + { tag: 'span', ref: 'eTitle', cls: `ag-group-title ag-${normalisedCssIdentifier}-group-title` }, ], }; } @@ -513,12 +519,17 @@ class DefaultTitleBar< private title: string | undefined; private suppressOpenCloseIcons: boolean = false; private readonly suppressKeyboardNavigation: boolean = false; + private expanded: boolean = true; + private disabled: boolean = false; private readonly eGroupOpenedIcon: HTMLElement = RefPlaceholder; private readonly eGroupClosedIcon: HTMLElement = RefPlaceholder; private readonly eTitle: HTMLElement = RefPlaceholder; - constructor(params: AgGroupComponentParams = {}) { + constructor( + params: AgGroupComponentParams = {}, + private readonly containerId?: string + ) { super(getDefaultTitleBarTemplate(params)); const { title, suppressOpenCloseIcons, suppressKeyboardNavigation } = params; @@ -575,14 +586,39 @@ class DefaultTitleBar< } public refreshOnExpand(expanded: boolean): void { - this.refreshAriaStatus(expanded); + this.expanded = expanded; + this.refreshAriaState(); this.refreshOpenCloseIcons(expanded); } - private refreshAriaStatus(expanded: boolean): void { - if (!this.suppressOpenCloseIcons) { - _setAriaExpanded(this.getGui(), expanded); + private refreshAriaState(): void { + const eGui = this.getGui(); + const interactive = this.title != null && !this.suppressKeyboardNavigation && !this.disabled; + // suppressed open/close icons means the group cannot collapse, so no disclosure button semantics + const collapsible = interactive && !this.suppressOpenCloseIcons; + + let role = 'presentation'; + + if (collapsible) { + role = 'button'; + } else if (interactive) { + role = 'group'; + } + + _setAriaRole(eGui, role); + + if (interactive) { + this.activateTabIndex([eGui]); + } else { + eGui.removeAttribute('tabindex'); } + + if (collapsible) { + _setAriaExpanded(eGui, this.expanded); + } else { + _removeAriaExpanded(eGui); + } + _setAriaControls(eGui, collapsible ? this.containerId : null); } private refreshOpenCloseIcons(expanded: boolean): void { @@ -616,8 +652,7 @@ class DefaultTitleBar< this.title = title; } - const disabled = eGui.classList.contains(TITLE_BAR_DISABLED_CLASS); - this.refreshDisabledStyles(disabled); + this.refreshAriaState(); return this; } @@ -635,25 +670,15 @@ class DefaultTitleBar< this.dispatchExpandChanged(true); } + this.refreshAriaState(); + return this; } public refreshDisabledStyles(disabled: boolean) { - const eGui = this.getGui(); - if (disabled) { - eGui.classList.add(TITLE_BAR_DISABLED_CLASS); - eGui.removeAttribute('tabindex'); - _setAriaRole(eGui, 'presentation'); - } else { - eGui.classList.remove(TITLE_BAR_DISABLED_CLASS); - if (typeof this.title === 'string' && !this.suppressKeyboardNavigation) { - this.activateTabIndex([eGui]); - _setAriaRole(eGui, 'group'); - } else { - eGui.removeAttribute('tabindex'); - _setAriaRole(eGui, 'presentation'); - } - } + this.disabled = disabled; + this.getGui().classList.toggle(TITLE_BAR_DISABLED_CLASS, disabled); + this.refreshAriaState(); } } diff --git a/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelList.ts b/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelList.ts index fd44731461b..d8d61594225 100644 --- a/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelList.ts +++ b/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelList.ts @@ -41,7 +41,7 @@ export class AgFiltersToolPanelList extends Component { + const gridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule, CellSelectionModule, IntegratedChartsModule.with(AgChartsEnterpriseModule)], + }); + + beforeAll(async () => { + await canvasPolyfill.init(); + }); + afterAll(() => canvasPolyfill.reset()); + afterEach(() => gridsManager.reset()); + + test('expandable groups are disclosure buttons; non-collapsible groups are plain groups', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { field: 'country', chartDataType: 'category' }, + { field: 'gold', chartDataType: 'series' }, + ], + rowData: [ + { country: 'Russia', gold: 3 }, + { country: 'USA', gold: 4 }, + ], + cellSelection: true, + popupParent: document.body, + }); + const chartRef = api.createRangeChart({ + cellRange: { columns: ['country', 'gold'] }, + chartType: 'groupedColumn', + })!; + await chartRef.chart.waitForUpdate(); + + api.openChartToolPanel({ chartId: chartRef.chartId, panel: 'format' }); + await chartRef.chart.waitForUpdate(); + + // The format panel builds its groups on the controller's next chartUpdated event, so poll + // for the panel DOM rather than assuming it is present once the chart has settled. + const topLevel = await waitFor(() => { + const titleBar = document.querySelector('.ag-charts-format-top-level-group-title-bar'); + expect(titleBar).not.toBeNull(); + return titleBar!; + }); + + // Top-level accordion groups (Chart, Legend, ...) expand and collapse: disclosure buttons, + // announcing their state and wired via aria-controls to the container they show/hide. + expect(topLevel?.getAttribute('role')).toBe('button'); + expect(topLevel?.getAttribute('tabindex')).toBe('0'); + expect(topLevel?.hasAttribute('aria-expanded')).toBe(true); + const controlled = document.getElementById(topLevel?.getAttribute('aria-controls') ?? ''); + expect(controlled?.classList.contains('ag-group-container')).toBe(true); + + // The Padding sub-group cannot collapse (open/close icons suppressed), so it is a labelled + // group with no disclosure state — but still focusable for keyboard navigation. + const subLevelBars = Array.from(document.querySelectorAll('.ag-charts-format-sub-level-group-title-bar')); + const padding = subLevelBars.find( + (bar) => bar.querySelector('.ag-group-title')?.textContent?.trim() === 'Padding' + ); + expect(padding).toBeDefined(); + expect(padding?.getAttribute('role')).toBe('group'); + expect(padding?.getAttribute('tabindex')).toBe('0'); + expect(padding?.hasAttribute('aria-expanded')).toBe(false); + expect(padding?.hasAttribute('aria-controls')).toBe(false); + }); +}); diff --git a/testing/behavioural/src/filters/filter-behaviour/filters-tool-panel.test.ts b/testing/behavioural/src/filters/filter-behaviour/filters-tool-panel.test.ts index 9a05f9446e7..50e4d638cfe 100644 --- a/testing/behavioural/src/filters/filter-behaviour/filters-tool-panel.test.ts +++ b/testing/behavioural/src/filters/filter-behaviour/filters-tool-panel.test.ts @@ -153,6 +153,48 @@ describe('Filters Tool Panel', () => { expect(panel.isGroupExpandedByTitle('Person')).toBe(true); }); + test('group title bars are disclosure buttons wired to the container they expand', async () => { + const api = await gridsManager.createGridAndWait('grid1', { + columnDefs: [ + { + headerName: 'Person', + children: [ + { field: 'name', filter: 'agTextColumnFilter', filterParams: { debounceMs: 0 } }, + { field: 'age', filter: 'agNumberColumnFilter', filterParams: { debounceMs: 0 } }, + ], + }, + ], + rowData: [{ name: 'Alice', age: 30 }], + sideBar: FILTERS_SIDEBAR, + }); + const panel = await openFiltersPanel(api); + + // The list container carries the panel's label, which only names it under a role that + // permits naming (a bare div's aria-label is ignored). + const listPanel = document.querySelector('.ag-filter-list-panel'); + expect(listPanel?.getAttribute('role')).toBe('group'); + expect(listPanel?.getAttribute('aria-label')).toMatch(/^Filter List \d+ Filters$/); + + // An expandable group's title bar is a disclosure button: focusable, announcing its state, + // and wired via aria-controls to the container it shows/hides. + const titleBar = document.querySelector('.ag-filter-toolpanel-group-wrapper .ag-group-title-bar'); + expect(titleBar?.getAttribute('role')).toBe('button'); + expect(titleBar?.getAttribute('tabindex')).toBe('0'); + expect(titleBar?.getAttribute('aria-expanded')).toBe('true'); + + const container = document.getElementById(titleBar?.getAttribute('aria-controls') ?? ''); + expect(container?.classList.contains('ag-group-container')).toBe(true); + expect(container?.querySelector('.ag-filter-toolpanel-instance')).not.toBeNull(); + + await panel.collapseGroup('Person'); + expect(titleBar?.getAttribute('aria-expanded')).toBe('false'); + + // Space re-expands it, as button semantics promise. + titleBar?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + expect(titleBar?.getAttribute('aria-expanded')).toBe('true'); + expect(panel.isGroupExpandedByTitle('Person')).toBe(true); + }); + test('column suppressFiltersToolPanel hides the column from the panel but keeps it filterable', async () => { const api = await gridsManager.createGridAndWait('grid1', { columnDefs: [