Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 54 additions & 29 deletions packages/ag-grid-enterprise/src/agStack/agGroupComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
AgComponentStub,
RefPlaceholder,
_isComponent,
_removeAriaExpanded,
_removeFromParent,
_setAriaControls,
_setAriaExpanded,
_setAriaRole,
_setDisplayed,
Expand Down Expand Up @@ -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,
Expand All @@ -420,7 +424,7 @@ export class AgGroupComponent<
TCommon,
TPropertiesService,
TComponentSelectorType
>(this.params)
>(this.params, containerId)
);
this.eTitleBar = titleBar;
titleBar.refreshOnExpand(this.expanded);
Expand Down Expand Up @@ -471,26 +475,28 @@ const TITLE_BAR_DISABLED_CLASS = 'ag-disabled-group-title-bar';
function getDefaultTitleBarTemplate<TBeanCollection, TComponentSelectorType extends string>(
params: AgGroupComponentParams<TBeanCollection>
): AgElementParams<TComponentSelectorType> {
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` },
],
};
}
Expand All @@ -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<TBeanCollection> = {}) {
constructor(
params: AgGroupComponentParams<TBeanCollection> = {},
private readonly containerId?: string
) {
super(getDefaultTitleBarTemplate(params));

const { title, suppressOpenCloseIcons, suppressKeyboardNavigation } = params;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class AgFiltersToolPanelList extends Component<AgFiltersToolPanelListEven
private onColumnsChangedPending: boolean = false;

constructor() {
super({ tag: 'div', cls: 'ag-filter-list-panel' });
super({ tag: 'div', cls: 'ag-filter-list-panel', role: 'group' });
}

public init(params: ToolPanelFiltersCompParams): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { waitFor } from '@testing-library/dom';
import { AgChartsEnterpriseModule } from 'ag-charts-enterprise';
import { TestGridsManager, canvasPolyfill } from 'ag-test-utils';

import { ClientSideRowModelModule } from 'ag-grid-community';
import { CellSelectionModule, IntegratedChartsModule } from 'ag-grid-enterprise';

/**
* Aria contract of the format panel's group title bars, in its own suite because the sibling
* `format-panel-options-*` suites own the option *bindings*, not the panel DOM. The format panel is
* the one surface reachable through the public API that renders both title-bar states of the shared
* group component: expandable groups (disclosure buttons) and non-collapsible groups, which have
* their open/close icons suppressed and must not present as disclosure buttons.
*/
describe('chart format panel title bar roles', () => {
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading