From 31051cbd7dbef9f41ed7e5775e4985828c1aebd8 Mon Sep 17 00:00:00 2001 From: Tomasz Kasowicz Date: Wed, 23 Sep 2026 16:17:30 +0200 Subject: [PATCH] feat(aria/grid): add CDK table interop Adds interoperability directives which allow users to use `ngGrid` with CDK tables. In native table `` is always a parent of ``. However in CDK table `` elements are defined outside of `` elements. This commit adds directives which allow users to bridge `ngGridRow` and `ngGridCell`. Usage: ```typescript @Component({ template: `
Name {{ row.name }}
`, providers: [NgGridRowRegistryForCdk], }) export class CdkTableInteropComponent { readonly displayedColumns = ['name']; } ``` Fixes: #32603 --- src/aria/grid/BUILD.bazel | 1 + src/aria/grid/cdk-table-interop.ts | 87 ++++++++++++++++ src/aria/grid/grid.spec.ts | 154 ++++++++++++++++++++++++++++- src/aria/grid/public-api.ts | 5 + 4 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/aria/grid/cdk-table-interop.ts diff --git a/src/aria/grid/BUILD.bazel b/src/aria/grid/BUILD.bazel index ec62fa5648f1..d0da513fa754 100644 --- a/src/aria/grid/BUILD.bazel +++ b/src/aria/grid/BUILD.bazel @@ -29,6 +29,7 @@ ng_project( "//:node_modules/@angular/platform-browser", "//:node_modules/axe-core", "//src/aria/private/testing", + "//src/cdk/table", "//src/cdk/testing/private", ], ) diff --git a/src/aria/grid/cdk-table-interop.ts b/src/aria/grid/cdk-table-interop.ts new file mode 100644 index 000000000000..9c7ecbad7a5e --- /dev/null +++ b/src/aria/grid/cdk-table-interop.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Directive, inject, Injectable} from '@angular/core'; +import {GridRow} from './grid-row'; +import {GRID_ROW} from './grid-tokens'; + +/** + * Grid Row Registry used to track the most recent grid row in the CDK table. + * It should be provided in the component that uses the CDK table. + * + * ```typescript + * @Component({ + * providers: [NgGridRowRegistry], + * }) + * export class ComponentUsingCdkTable {} + * ``` + */ +@Injectable() +export class NgGridRowRegistryForCdk { + mostRecentRow: GridRow | null = null; +} + +/** + * Bridges the parent `GridRow` into a CDK table cell. + * + * `` requires parent `` directive to provide GRID_ROW Token + * However in CDK tables `` is defined separately from ``. + * Therefore we need to bridge `ngGridRow` defined in CDK `` into CDK Cell + * + * This directive provides `ngGridRow` to `` + * + * ```html + * + * + * + * + * + *
Name {{row.name}}
+ * ``` + */ +@Directive({ + selector: '[ngProvideGridRowForCdk]', + providers: [ + { + provide: GRID_ROW, + useFactory: () => { + const row = inject(NgGridRowRegistryForCdk).mostRecentRow; + if (!row) { + throw new Error('ngProvideGridRowForCdk: no ngGridRow registered in NgGridRowRegistry.'); + } + return row; + }, + }, + ], +}) +export class ProvideNgGridRowForCdkDirective {} + +/** + * Registers `ngGridRow` in NgGridRowRegistry. + * + * ```html + * + * + * + *
+ * ``` + */ +@Directive({ + selector: '[ngRegisterGridRowForCdk]', +}) +export class RegisterGridRowForCdkDirective { + constructor() { + inject(NgGridRowRegistryForCdk).mostRecentRow = inject(GridRow); + } +} diff --git a/src/aria/grid/grid.spec.ts b/src/aria/grid/grid.spec.ts index 4041ca97d2ae..5ad333b42459 100644 --- a/src/aria/grid/grid.spec.ts +++ b/src/aria/grid/grid.spec.ts @@ -1,4 +1,4 @@ -import {Component, DebugElement, signal, ChangeDetectionStrategy} from '@angular/core'; +import {Component, DebugElement, signal, ChangeDetectionStrategy, Type} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {Grid} from './grid'; @@ -7,6 +7,13 @@ import {GridCell} from './grid-cell'; import {GridCellWidget} from './grid-cell-widget'; import {GRID_ROW, GRID_CELL} from './grid-tokens'; import {waitForMicrotasks} from '../private/testing/test-helpers'; +import { + NgGridRowRegistryForCdk, + ProvideNgGridRowForCdkDirective, + RegisterGridRowForCdkDirective, +} from './cdk-table-interop'; +import {CdkTableModule} from '@angular/cdk/table'; +import {computed} from '../private'; interface ModifierKeys { ctrlKey?: boolean; @@ -99,10 +106,11 @@ describe('Grid directives', () => { selectionMode?: 'follow' | 'explicit'; gridData?: RowConfig[]; tabIndex?: number; + component?: Type; }) { TestBed.resetTestingModule(); TestBed.configureTestingModule({}); - fixture = TestBed.createComponent(GridTestComponent); + fixture = TestBed.createComponent(opts?.component ?? GridTestComponent); const testComponent = fixture.componentInstance; if (opts?.disabled !== undefined) testComponent.disabled.set(opts.disabled); @@ -1192,6 +1200,106 @@ describe('Grid directives', () => { expect(cells[1].nativeElement.getAttribute('role')).toBe('gridcell'); }); }); + + describe('CDK table interop', () => { + it('should set role="row" on the host element', async () => { + await setupGrid({component: CdkTableInteropTestComponent}); + const row = gridElement.querySelector('tr') as HTMLElement; + expect(row.getAttribute('role')).toBe('row'); + }); + + it('should activate the cell when the grid receives focusin', async () => { + await setupGrid({component: CdkTableInteropTestComponent}); + + // Let effect run to set default state which sets initial active cell + gridInstance._pattern.setDefaultStateEffect(); + + const cell1 = fixture.debugElement.query(By.directive(GridCell)).nativeElement; + + // Dispatch focusin to the cell + cell1.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + await fixture.whenStable(); + + expect(gridInstance._pattern.activeCell()?.element()).toBe(cell1); + expect(gridInstance._pattern.isFocused()).toBeTrue(); + }); + + it('should deactivate the grid when focusout moves outside the grid', async () => { + await setupGrid({component: CdkTableInteropTestComponent}); + + const cell1 = fixture.debugElement.query(By.directive(GridCell)).nativeElement; + + // Focus first + + gridInstance._pattern.setDefaultStateEffect(); + cell1.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + await fixture.whenStable(); + expect(gridInstance._pattern.isFocused()).toBeTrue(); + + // Focusout (blur) + // Add relatedTarget so we simulate moving focus out completely, otherwise the target doesn't update correctly + const focusOutEvent = new FocusEvent('focusout', { + bubbles: true, + relatedTarget: document.body, + }); + cell1.dispatchEvent(focusOutEvent); + await fixture.whenStable(); + + expect(gridInstance._pattern.isFocused()).toBeFalse(); + }); + + describe('keyboard interactions', () => { + describe('navigation keys', () => { + beforeEach(async () => { + await setupGrid({component: CdkTableInteropTestComponent}); + // Let effect run to set default state which sets initial active cell + gridInstance._pattern.setDefaultStateEffect(); + await fixture.whenStable(); + + // Start interactions from the middle cell (c1-1) + const centerCell = gridElement.querySelector('#c1-1') as HTMLElement; + centerCell.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + await fixture.whenStable(); + }); + + it('should move focus up to the previous row on ArrowUp', async () => { + await up(); + + expect(getActiveCellId()).toBe('c0-1'); + }); + + it('should move focus down to the next row on ArrowDown', async () => { + await down(); + + expect(getActiveCellId()).toBe('c2-1'); + }); + + it('should move focus left to the previous column on ArrowLeft', async () => { + await left(); + + expect(getActiveCellId()).toBe('c1-0'); + }); + + it('should move focus right to the next column on ArrowRight', async () => { + await right(); + + expect(getActiveCellId()).toBe('c1-2'); + }); + + it('should move focus to the first cell in the row on Home', async () => { + await home(); + + expect(getActiveCellId()).toBe('c1-0'); + }); + + it('should move focus to the last cell in the row on End', async () => { + await end(); + + expect(getActiveCellId()).toBe('c1-2'); + }); + }); + }); + }); }); @Component({ @@ -1307,3 +1415,45 @@ class MyCustomRow extends GridRow {} changeDetection: ChangeDetectionStrategy.Eager, }) class SubclassGridTestComponent {} + +@Component({ + template: ` + + + + + + + + + + + + + + + +
ID1 {{row.id1}} ID2 {{row.id2}} ID3 {{row.id3}}
+ `, + imports: [ + CdkTableModule, + Grid, + GridRow, + GridCell, + ProvideNgGridRowForCdkDirective, + RegisterGridRowForCdkDirective, + ], + changeDetection: ChangeDetectionStrategy.Eager, + providers: [NgGridRowRegistryForCdk], +}) +class CdkTableInteropTestComponent extends GridTestComponent { + readonly cdkColumns = signal(['id1', 'id2', 'id3']); + readonly cdkGridData = computed(() => { + const cdkData = this.gridData() + .map(data => data.cells) + .map(cells => cells.map(cell => cell.id)) + .map(([id1, id2, id3]) => ({id1, id2, id3})); + console.log('cdkData', cdkData); + return cdkData; + }); +} diff --git a/src/aria/grid/public-api.ts b/src/aria/grid/public-api.ts index b8c416da44f2..81033e890b34 100644 --- a/src/aria/grid/public-api.ts +++ b/src/aria/grid/public-api.ts @@ -11,3 +11,8 @@ export {GridCell} from './grid-cell'; export {GridRow} from './grid-row'; export {GridCellWidget} from './grid-cell-widget'; export {GRID, GRID_ROW, GRID_CELL} from './grid-tokens'; +export { + NgGridRowRegistryForCdk, + ProvideNgGridRowForCdkDirective, + RegisterGridRowForCdkDirective, +} from './cdk-table-interop';