Skip to content
Closed
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
36 changes: 36 additions & 0 deletions src/cdk/a11y/aria-grid/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
load(
"//tools:defaults.bzl",
"ng_project",
"ng_web_test_suite",
)

package(default_visibility = ["//visibility:public"])

ng_project(
name = "aria-grid",
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
deps = [
"//:node_modules/@angular/core",
"//src/cdk/table",
],
)

ng_project(
name = "unit_test_sources",
testonly = True,
srcs = glob(["**/*.spec.ts"]),
deps = [
":aria-grid",
"//:node_modules/@angular/core",
"//:node_modules/@angular/platform-browser",
"//src/cdk/table",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_test_sources"],
)
77 changes: 77 additions & 0 deletions src/cdk/a11y/aria-grid/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ARIA Grid with CDK Table Integration

This module provides ARIA grid directives that work seamlessly with Angular CDK Table, solving the DI tree issue that occurs when using templates and portals.

## The Problem

When using ARIA grid directives with CDK table, the `ngGridCell` directive fails to find the `GRID_ROW` provider because CDK table renders cells through templates and portals, which breaks the normal Angular DI tree.

## The Solution

This implementation provides multiple approaches to solve the DI issue:

### 1. Simple Row Provider (Recommended)

Use the `cdkGridRowProvider` directive on your table rows:

```typescript
@Component({
imports: [CdkTableModule, Grid, GridRow, GridCell, CdkGridRowProvider],
template: `
<table ngGrid cdk-table [dataSource]="data">
@for (column of columns; track column) {
<ng-container [cdkColumnDef]="column">
<th ngGridCell cdk-header-cell *cdkHeaderCellDef>{{ column }}</th>
<td ngGridCell cdk-cell *cdkCellDef="let row">{{ row[column] }}</td>
</ng-container>
}
<tr ngGridRow cdkGridRowProvider cdk-header-row *cdkHeaderRowDef="columns"></tr>
<tr ngGridRow cdkGridRowProvider cdk-row *cdkRowDef="let row; columns: columns"></tr>
</table>
`
})
export class MyTable {
data = [
{ name: 'John', age: 30 },
{ name: 'Jane', age: 25 }
];
columns = ['name', 'age'];
}
```

### 2. DOM-based Fallback

The `GridCell` directive automatically falls back to DOM traversal when DI fails, so it works without additional configuration in most cases.

## Key Features

- **Automatic DI Fallback**: GridCell automatically searches the DOM hierarchy when DI fails
- **CDK Table Integration**: Seamless integration with existing CDK table implementations
- **Minimal Code Changes**: Only requires adding `cdkGridRowProvider` to row elements
- **Performance Optimized**: Uses efficient DOM traversal and caching
- **Type Safe**: Full TypeScript support with proper typing

## API Reference

### Directives

- `Grid` - Main grid container (`[ngGrid]`)
- `GridRow` - Grid row (`[ngGridRow]`)
- `GridCell` - Grid cell (`[ngGridCell]`)
- `CdkGridRowProvider` - CDK table row provider (`[cdkGridRowProvider]`)

### Tokens

- `GRID_ROW` - Injection token for grid row instances

## Migration Guide

To migrate existing CDK tables to use ARIA grid:

1. Add `ngGrid` to your table element
2. Add `ngGridRow` to your row templates
3. Add `ngGridCell` to your cell templates
4. Add `cdkGridRowProvider` to CDK row templates
5. Import the required directives in your component

The solution is backward compatible and doesn't affect existing CDK table functionality.
27 changes: 27 additions & 0 deletions src/cdk/a11y/aria-grid/cdk-grid-row-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @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, ElementRef, inject, forwardRef} from '@angular/core';
import {GRID_ROW} from './tokens';

/**
* Directive that provides GRID_ROW token for CDK table rows.
* Apply this to tr elements that use ngGridRow in CDK tables.
*
* Usage:
* <tr ngGridRow cdkGridRowProvider cdk-row *cdkRowDef="let row; columns: columns">
*/
@Directive({
selector: '[cdkGridRowProvider]',
providers: [{provide: GRID_ROW, useExisting: forwardRef(() => CdkGridRowProvider)}],
})
export class CdkGridRowProvider {
private _elementRef = inject(ElementRef);

constructor() {}
}
64 changes: 64 additions & 0 deletions src/cdk/a11y/aria-grid/grid-cell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @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,
ElementRef,
inject,
Optional,
SkipSelf,
Injector,
AfterViewInit,
} from '@angular/core';
import {GRID_ROW} from './tokens';

@Directive({
selector: '[ngGridCell]',
host: {
'role': 'gridcell',
},
})
export class GridCell implements AfterViewInit {
private _elementRef = inject(ElementRef);
private _injector = inject(Injector);
private _gridRow: any;

constructor() {
// Try to inject GRID_ROW, but don't fail if not found
try {
this._gridRow = this._injector.get(GRID_ROW, null, {optional: true, skipSelf: true});
} catch {
this._gridRow = null;
}
}

ngAfterViewInit(): void {
if (!this._gridRow) {
// Fallback: Look for grid row in DOM hierarchy when DI fails (e.g., in CDK table)
this._findGridRowInDom();
}
}

private _findGridRowInDom(): void {
let element = this._elementRef.nativeElement.parentElement;
while (element) {
if (element.hasAttribute('ngGridRow') || element.getAttribute('role') === 'row') {
// Found a grid row element, create a mock grid row reference
this._gridRow = {element};
break;
}
element = element.parentElement;
}

if (!this._gridRow) {
console.warn(
'GridCell: No grid row found in DOM hierarchy. This may indicate a setup issue.',
);
}
}
}
23 changes: 23 additions & 0 deletions src/cdk/a11y/aria-grid/grid-row.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @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, ElementRef, inject, forwardRef} from '@angular/core';
import {GRID_ROW} from './tokens';

@Directive({
selector: '[ngGridRow]',
providers: [{provide: GRID_ROW, useExisting: forwardRef(() => GridRow)}],
host: {
'role': 'row',
},
})
export class GridRow {
private _elementRef = inject(ElementRef);

constructor() {}
}
21 changes: 21 additions & 0 deletions src/cdk/a11y/aria-grid/grid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @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, ElementRef, inject} from '@angular/core';

@Directive({
selector: '[ngGrid]',
host: {
'role': 'grid',
},
})
export class Grid {
private _elementRef = inject(ElementRef);

constructor() {}
}
13 changes: 13 additions & 0 deletions src/cdk/a11y/aria-grid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* @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
*/

export * from './grid';
export * from './grid-row';
export * from './grid-cell';
export * from './tokens';
export * from './cdk-grid-row-provider';
12 changes: 12 additions & 0 deletions src/cdk/a11y/aria-grid/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* @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 {InjectionToken} from '@angular/core';

/** Injection token for the grid row. */
export const GRID_ROW = new InjectionToken<any>('GRID_ROW');
1 change: 1 addition & 0 deletions src/cdk/a11y/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
export * from './aria-describer/aria-describer';
export * from './aria-describer/aria-reference';
export * from './aria-grid';
export * from './key-manager/activedescendant-key-manager';
export * from './key-manager/focus-key-manager';
export * from './key-manager/list-key-manager';
Expand Down
2 changes: 1 addition & 1 deletion src/material/dialog/dialog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class MatDialogConfig<D = any> {
result: Result | undefined,
config: Config,
componentInstance: Component | null,
) => boolean;
) => boolean | Promise<boolean>;

/** Width of the dialog. */
width?: string = '';
Expand Down
23 changes: 21 additions & 2 deletions src/material/dialog/dialog-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,29 @@ export class MatDialogRef<T, R = any> {
close(dialogResult?: R): void {
const closePredicate = this._config.closePredicate;

if (closePredicate && !closePredicate(dialogResult, this._config, this.componentInstance)) {
return;
if (closePredicate) {
const result = closePredicate(dialogResult, this._config, this.componentInstance);

if (result instanceof Promise) {
result.then(canClose => {
if (canClose) {
this._performClose(dialogResult);
}
});
return;
} else if (!result) {
return;
}
}

this._performClose(dialogResult);
}

/**
* Performs the actual dialog close operation.
* @param dialogResult Optional result to return to the dialog opener.
*/
private _performClose(dialogResult?: R): void {
this._result = dialogResult;

// Transition the backdrop in parallel to the dialog.
Expand Down
44 changes: 44 additions & 0 deletions src/material/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,50 @@ describe('MatDialog', () => {

overlayContainerElement.remove();
}));

it('should support async closePredicate that returns a Promise', fakeAsync(() => {
let resolvePromise: (value: boolean) => void;
const closedSpy = jasmine.createSpy('closed spy');
const ref = dialog.open(PizzaMsg, {
closePredicate: () =>
new Promise<boolean>(resolve => {
resolvePromise = resolve;
}),
viewContainerRef: testViewContainerRef,
});

ref.afterClosed().subscribe(closedSpy);
viewContainerFixture.detectChanges();

expect(getDialogs().length).toBe(1);

// Try to close - should not close immediately
ref.close('test-result');
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(1);
expect(closedSpy).not.toHaveBeenCalled();

// Resolve promise with false - should still not close
resolvePromise!(false);
tick();
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(1);
expect(closedSpy).not.toHaveBeenCalled();

// Try to close again and resolve with true - should close
ref.close('test-result');
resolvePromise!(true);
tick();
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(0);
expect(closedSpy).toHaveBeenCalledWith('test-result');
}));
});

it(
Expand Down
Loading