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.
+ *
+ * `
` 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';
|