Skip to content

Commit bdeb272

Browse files
authored
Overhaul grid a11y, navigation and rendering (#79)
Screen readers were silent over the grid body: ARIA state lived on scattered attributes and DOM focus never reached the cells. Holding an arrow key re-rendered every visible row per keystroke. Accessibility - Implement the full ARIA grid pattern through ElementInternals: roles, row/column counts and indices, aria-sort, aria-selected. The filter row is modeled as a second header row. - Use roving focus. Navigation and click activation place real DOM focus on the active cell so assistive technology announces it; aria-activedescendant cannot cross shadow boundaries. The virtualizer reclaims focus when scrolling removes the focused cell. Performance - Keep the row renderer identity stable across activation. The virtualizer re-renders all rows when renderItem changes, so activation now updates only the two affected rows. - Derive column track sizes only when the configuration changes and notify context consumers at mutation points, not on every host update. API and structure - navigateTo(row, options) replaces positional arguments. Activation without a column keeps the current one instead of clearing it. - Rename internal tags to the igc-grid-lite-* prefix. - Defer filter row and editor element definitions until a filterable column renders. - Route all render-root queries through the DOM controller so other controllers no longer reach into the grid DOM directly. - Skip filter expressions whose conditions no column resolved.
1 parent e541ff8 commit bdeb272

35 files changed

Lines changed: 1244 additions & 238 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"scripts": {
2121
"analyze": "cem analyze --litelement --globs \"src/**/*.{js,ts}\" --exclude \"src/styles/**/*\"",
2222
"build": "npm run analyze && node scripts/build.js && npm run typedoc",
23+
"build:styles": "node scripts/build-styles.js",
2324
"build:tsc": "tsc",
2425
"dev:vite": "vite build",
2526
"format": "biome check --fix && npm run format:stylelint",

scripts/build-styles.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { buildComponents } from './sass.js';
2+
3+
await buildComponents();

src/components/cell.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { θaddAdoptedStylesController as addAdoptedStylesController } from 'igni
22
import { html, LitElement, type PropertyValues } from 'lit';
33
import { property } from 'lit/decorators.js';
44
import { cache } from 'lit/directives/cache.js';
5+
import { addA11y } from '../internal/a11y.js';
56
import { registerComponent } from '../internal/register.js';
67
import { GRID_CELL_TAG } from '../internal/tags.js';
78
import type { ColumnConfiguration, IgcCellContext, PropertyType } from '../internal/types.js';
@@ -23,10 +24,20 @@ export default class IgcGridLiteCell<T extends object> extends LitElement {
2324
}
2425

2526
private readonly _adoptedStylesController = addAdoptedStylesController(this);
27+
private readonly _a11y = addA11y(this, 'gridcell');
2628

2729
@property({ attribute: false })
2830
public adoptRootStyles = false;
2931

32+
/**
33+
* Position of the cell among the visible columns. Written as its 1-based
34+
* `aria-colindex`.
35+
*
36+
* @internal
37+
*/
38+
@property({ attribute: false })
39+
public _colIndex = -1;
40+
3041
/**
3142
* The value which will be rendered by the component.
3243
*/
@@ -73,6 +84,10 @@ export default class IgcGridLiteCell<T extends object> extends LitElement {
7384

7485
public override connectedCallback(): void {
7586
super.connectedCallback();
87+
88+
// Roving focus target. Navigation moves DOM focus here so that assistive
89+
// technology announces the cell.
90+
this.tabIndex = -1;
7691
this._adoptedStylesController.shouldAdoptStyles(this._shouldAdoptStyles);
7792
}
7893

@@ -81,6 +96,12 @@ export default class IgcGridLiteCell<T extends object> extends LitElement {
8196
this._adoptedStylesController.shouldAdoptStyles(this._shouldAdoptStyles);
8297
}
8398

99+
// The grid has no cell selection. `aria-selected` marks only the active cell.
100+
this._a11y.set({
101+
ariaColIndex: `${this._colIndex}`,
102+
ariaSelected: this.active ? 'true' : null,
103+
});
104+
84105
super.update(props);
85106
}
86107

src/components/filter-row.ts

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { consume } from '@lit/context';
22
import {
33
θaddAdoptedStylesController as addAdoptedStylesController,
44
θaddThemingController as addThemingController,
5+
IgcButtonComponent,
6+
IgcChipComponent,
57
IgcDropdownComponent,
68
type IgcDropdownItemComponent,
79
type IgcIconComponent,
@@ -11,11 +13,12 @@ import { html, LitElement, nothing, type PropertyValues } from 'lit';
1113
import { property, query } from 'lit/decorators.js';
1214
import { ifDefined } from 'lit/directives/if-defined.js';
1315
import type { StateController } from '../controllers/state.js';
16+
import { addA11y, FILTER_ROW_INDEX } from '../internal/a11y.js';
1417
import { DEFAULT_COLUMN_CONFIG } from '../internal/constants.js';
1518
import { GRID_STATE_CONTEXT } from '../internal/context.js';
1619
import { registerComponent } from '../internal/register.js';
1720
import { GRID_FILTER_ROW_TAG } from '../internal/tags.js';
18-
import type { ColumnConfiguration, PropertyType } from '../internal/types.js';
21+
import type { ColumnConfiguration, Keys, PropertyType } from '../internal/types.js';
1922
import { getFilterOperandsFor } from '../internal/utils.js';
2023
import { watch } from '../internal/watch.js';
2124
import type { FilterExpressionTree } from '../operations/filter/tree.js';
@@ -26,6 +29,9 @@ import { all } from '../styles/themes/filtering-row-themes.js';
2629
/** Number of filter expressions shown as chips before collapsing into a single counted chip. */
2730
const MAX_PREVIEW_CHIPS = 3;
2831

32+
/** Accessible name of the filter row itself. */
33+
const FILTER_ROW_LABEL = 'Column filters';
34+
2935
type ExpressionChipProps<T> = {
3036
expression: FilterExpression<T>;
3137
selected: boolean;
@@ -50,8 +56,18 @@ export default class IgcFilterRow<T extends object> extends LitElement {
5056

5157
public static override styles = styles;
5258

59+
/**
60+
* Only the filter row renders these editor components, so the grid defers this
61+
* call until a filterable column shows one.
62+
*/
5363
public static register() {
54-
registerComponent(IgcFilterRow);
64+
registerComponent(
65+
IgcFilterRow,
66+
IgcButtonComponent,
67+
IgcChipComponent,
68+
IgcInputComponent,
69+
IgcDropdownComponent
70+
);
5571
}
5672

5773
private readonly _adoptedStylesController = addAdoptedStylesController(this);
@@ -96,6 +112,15 @@ export default class IgcFilterRow<T extends object> extends LitElement {
96112
constructor() {
97113
super();
98114

115+
// `role=grid` permits only rows and rowgroups as children, so a `search`
116+
// landmark here would be an illegal node in the grid tree. The filter row is
117+
// modeled as the second header row: one `gridcell` per column, each with that
118+
// column's filter controls.
119+
addA11y(this, 'row').set({
120+
ariaRowIndex: `${FILTER_ROW_INDEX}`,
121+
ariaLabel: FILTER_ROW_LABEL,
122+
});
123+
99124
addThemingController(this, all, {
100125
themeChange: this._handleThemeChange,
101126
});
@@ -203,6 +228,25 @@ export default class IgcFilterRow<T extends object> extends LitElement {
203228
this.dropdown.toggle(this.input);
204229
}
205230

231+
#handleConditionKeydown(event: KeyboardEvent) {
232+
if (event.key !== 'Enter' && event.key !== ' ') {
233+
return;
234+
}
235+
236+
// The trigger sits in the prefix slot of the input. The keystroke must not
237+
// reach the input and commit a filter behind the open dropdown.
238+
event.preventDefault();
239+
event.stopPropagation();
240+
241+
this.#openDropdownList();
242+
}
243+
244+
/** Header text of a column. Names the filter controls that act on it. */
245+
#nameFor(field: Keys<T>): string {
246+
const column = this.state.columns.find((each) => each.field === field);
247+
return String(column?.header ?? field);
248+
}
249+
206250
@watch('active', { waitUntilFirstUpdate: true })
207251
protected activeChanged() {
208252
this.style.display = this.active ? 'flex' : '';
@@ -211,7 +255,8 @@ export default class IgcFilterRow<T extends object> extends LitElement {
211255
this.column = DEFAULT_COLUMN_CONFIG as ColumnConfiguration<T>;
212256
}
213257

214-
this.state.host.requestUpdate();
258+
// The header row marks the column whose filter editor is open.
259+
this.state.updateObservers();
215260
}
216261

217262
#chipCriteriaFor(expression: FilterExpression<T>) {
@@ -268,10 +313,18 @@ export default class IgcFilterRow<T extends object> extends LitElement {
268313

269314
const prefix = html`<span slot="select"></span>${prefixedIcon(name)}`;
270315

316+
// The chip renders its select and remove actions in its own shadow root.
317+
// Resource strings are the only way to name them after the expression.
318+
const expression = `${this.#nameFor(props.expression.key)} ${name} ${unary ? '' : term}`.trim();
319+
271320
return html`
272321
<igc-chip
273322
selectable
274323
removable
324+
.resourceStrings=${{
325+
chip_remove: `Remove filter ${expression}`,
326+
chip_select: `Edit filter ${expression}`,
327+
}}
275328
?selected=${props.selected}
276329
@igcRemove=${props.onRemove}
277330
@igcSelect=${props.onSelect}
@@ -341,22 +394,31 @@ export default class IgcFilterRow<T extends object> extends LitElement {
341394
}
342395

343396
protected renderDropdownTarget() {
397+
const condition = this.condition.label ?? this.condition.name;
398+
344399
return html`<igc-icon
345400
id="condition"
346401
slot="prefix"
347402
collection="internal"
403+
role="button"
404+
tabindex="0"
405+
aria-haspopup="listbox"
406+
aria-label=${`Filter condition: ${condition}`}
348407
.name=${this.condition.name}
349408
@click=${this.#openDropdownList}
409+
@keydown=${this.#handleConditionKeydown}
350410
>
351411
</igc-icon>`;
352412
}
353413

354414
protected renderInputArea() {
415+
// `igc-input` names its inner control from a slotted label or the placeholder
416+
// only, so the placeholder carries the column name.
355417
return html`
356418
<igc-input
357419
outlined
358420
value=${ifDefined(this.expression.searchTerm)}
359-
placeholder="Add filter value"
421+
placeholder=${`Filter ${this.#nameFor(this.column.field)}`}
360422
?readonly=${this.condition.unary}
361423
@igcInput=${this.#handleInput}
362424
@keydown=${this.#handleKeydown}
@@ -369,7 +431,10 @@ export default class IgcFilterRow<T extends object> extends LitElement {
369431

370432
protected renderActiveState() {
371433
return html`
372-
<div part="active-state">
434+
<div
435+
part="active-state"
436+
role="gridcell"
437+
>
373438
<div part="filter-row-input">${this.renderInputArea()}</div>
374439
<div part="filter-row-filters">${this.renderActiveChips()}</div>
375440
<div part="filter-row-actions">${this.renderFilterActions()}</div>
@@ -410,6 +475,7 @@ export default class IgcFilterRow<T extends object> extends LitElement {
410475
const count = hidden ? html`<span slot="suffix">${state.length}</span>` : nothing;
411476
const chip = html`<igc-chip
412477
data-column=${column.field}
478+
aria-label=${`Filter ${this.#nameFor(column.field)}`}
413479
@click=${open}
414480
>${prefixedIcon('filter')}Filter${count}</igc-chip
415481
>`;
@@ -418,15 +484,19 @@ export default class IgcFilterRow<T extends object> extends LitElement {
418484
}
419485

420486
protected renderInactiveState() {
421-
return this.state.columns.map((column) =>
422-
column.hidden
423-
? nothing
424-
: html`
425-
<div part="filter-row-preview">
426-
${column.filterable ? this.renderFilterState(column) : nothing}
427-
</div>
428-
`
429-
);
487+
return this.state.columns
488+
.filter((column) => !column.hidden)
489+
.map(
490+
(column, index) => html`
491+
<div
492+
part="filter-row-preview"
493+
role="gridcell"
494+
aria-colindex=${index + 1}
495+
>
496+
${column.filterable ? this.renderFilterState(column) : nothing}
497+
</div>
498+
`
499+
);
430500
}
431501

432502
protected override render() {

0 commit comments

Comments
 (0)