diff --git a/src/cdk/a11y/aria-grid/BUILD.bazel b/src/cdk/a11y/aria-grid/BUILD.bazel new file mode 100644 index 000000000000..fecd4c31440a --- /dev/null +++ b/src/cdk/a11y/aria-grid/BUILD.bazel @@ -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"], +) diff --git a/src/cdk/a11y/aria-grid/README.md b/src/cdk/a11y/aria-grid/README.md new file mode 100644 index 000000000000..998e7a9d8da7 --- /dev/null +++ b/src/cdk/a11y/aria-grid/README.md @@ -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: ` + + @for (column of columns; track column) { + + + + + } + + +
{{ column }}{{ row[column] }}
+ ` +}) +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. \ No newline at end of file diff --git a/src/cdk/a11y/aria-grid/cdk-grid-row-provider.ts b/src/cdk/a11y/aria-grid/cdk-grid-row-provider.ts new file mode 100644 index 000000000000..8af9577ee070 --- /dev/null +++ b/src/cdk/a11y/aria-grid/cdk-grid-row-provider.ts @@ -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: + * + */ +@Directive({ + selector: '[cdkGridRowProvider]', + providers: [{provide: GRID_ROW, useExisting: forwardRef(() => CdkGridRowProvider)}], +}) +export class CdkGridRowProvider { + private _elementRef = inject(ElementRef); + + constructor() {} +} diff --git a/src/cdk/a11y/aria-grid/grid-cell.ts b/src/cdk/a11y/aria-grid/grid-cell.ts new file mode 100644 index 000000000000..ee7744a53064 --- /dev/null +++ b/src/cdk/a11y/aria-grid/grid-cell.ts @@ -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.', + ); + } + } +} diff --git a/src/cdk/a11y/aria-grid/grid-row.ts b/src/cdk/a11y/aria-grid/grid-row.ts new file mode 100644 index 000000000000..2fb986b77829 --- /dev/null +++ b/src/cdk/a11y/aria-grid/grid-row.ts @@ -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() {} +} diff --git a/src/cdk/a11y/aria-grid/grid.ts b/src/cdk/a11y/aria-grid/grid.ts new file mode 100644 index 000000000000..821c351bb597 --- /dev/null +++ b/src/cdk/a11y/aria-grid/grid.ts @@ -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() {} +} diff --git a/src/cdk/a11y/aria-grid/index.ts b/src/cdk/a11y/aria-grid/index.ts new file mode 100644 index 000000000000..5987d45547ac --- /dev/null +++ b/src/cdk/a11y/aria-grid/index.ts @@ -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'; diff --git a/src/cdk/a11y/aria-grid/tokens.ts b/src/cdk/a11y/aria-grid/tokens.ts new file mode 100644 index 000000000000..73b81ce23551 --- /dev/null +++ b/src/cdk/a11y/aria-grid/tokens.ts @@ -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('GRID_ROW'); diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index df192ec2c339..547a49bb67a6 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -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'; diff --git a/src/material/dialog/dialog-config.ts b/src/material/dialog/dialog-config.ts index 0cfb0ae86234..21b7b2ccaa8a 100644 --- a/src/material/dialog/dialog-config.ts +++ b/src/material/dialog/dialog-config.ts @@ -77,7 +77,7 @@ export class MatDialogConfig { result: Result | undefined, config: Config, componentInstance: Component | null, - ) => boolean; + ) => boolean | Promise; /** Width of the dialog. */ width?: string = ''; diff --git a/src/material/dialog/dialog-ref.ts b/src/material/dialog/dialog-ref.ts index c247fad81cb1..4f4b191c6756 100644 --- a/src/material/dialog/dialog-ref.ts +++ b/src/material/dialog/dialog-ref.ts @@ -123,10 +123,29 @@ export class MatDialogRef { 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. diff --git a/src/material/dialog/dialog.spec.ts b/src/material/dialog/dialog.spec.ts index d68cc19fe351..5d51e6d55aeb 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -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(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(