Skip to content

Commit 4138aca

Browse files
leonsenftNothingEverHappens
authored andcommitted
feat(core): render ARIA property bindings as attributes (#62630)
Allow binding to ARIA attributes using property binding syntax _without_ the `attr.` prefix. For example, `[aria-label]="expr"` is now valid, and equivalent to `[ariaLabel]="expr"`. Both examples bind to either a matching input or the `aria-label` HTML attribute, rather than the `ariaLabel` DOM property. Binding ARIA properties as attributes will ensure they are rendered correctly on the server, where the emulated DOM may not correctly reflect ARIA properties as attributes. Reuse the DOM schema registry from the compiler to map property names in type check blocks. PR Close #62630
1 parent db3c928 commit 4138aca

File tree

25 files changed

+608
-79
lines changed

25 files changed

+608
-79
lines changed

packages/compiler-cli/src/ngtsc/typecheck/src/dom.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {makeTemplateDiagnostic} from '../diagnostics';
2020

2121
import {TypeCheckSourceResolver} from './tcb_util';
2222

23-
const REGISTRY = new DomElementSchemaRegistry();
23+
export const REGISTRY = new DomElementSchemaRegistry();
2424
const REMOVE_XHTML_REGEX = /^:xhtml:/;
2525

2626
/**

packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ import {
7070
wrapForDiagnostics,
7171
wrapForTypeChecker,
7272
} from './diagnostics';
73-
import {DomSchemaChecker} from './dom';
73+
import {DomSchemaChecker, REGISTRY} from './dom';
7474
import {Environment} from './environment';
7575
import {astToTypescript, getAnyExpression} from './expression';
7676
import {OutOfBandDiagnosticRecorder} from './oob';
@@ -1206,7 +1206,7 @@ class TcbDomSchemaCheckerOp extends TcbOp {
12061206

12071207
if (isPropertyBinding && binding.name !== 'style' && binding.name !== 'class') {
12081208
// A direct binding to a property.
1209-
const propertyName = ATTR_TO_PROP.get(binding.name) ?? binding.name;
1209+
const propertyName = REGISTRY.getMappedPropName(binding.name);
12101210

12111211
if (isTemplateElement) {
12121212
this.tcb.domSchemaChecker.checkTemplateElementProperty(
@@ -1418,21 +1418,6 @@ class TcbComponentNodeOp extends TcbOp {
14181418
}
14191419
}
14201420

1421-
/**
1422-
* Mapping between attributes names that don't correspond to their element property names.
1423-
* Note: this mapping has to be kept in sync with the equally named mapping in the runtime.
1424-
*/
1425-
const ATTR_TO_PROP = new Map(
1426-
Object.entries({
1427-
'class': 'className',
1428-
'for': 'htmlFor',
1429-
'formaction': 'formAction',
1430-
'innerHtml': 'innerHTML',
1431-
'readonly': 'readOnly',
1432-
'tabindex': 'tabIndex',
1433-
}),
1434-
);
1435-
14361421
/**
14371422
* A `TcbOp` which generates code to check "unclaimed inputs" - bindings on an element which were
14381423
* not attributed to any directive or component, and are instead processed against the HTML element
@@ -1481,7 +1466,7 @@ class TcbUnclaimedInputsOp extends TcbOp {
14811466
elId = this.scope.resolve(this.target);
14821467
}
14831468
// A direct binding to a property.
1484-
const propertyName = ATTR_TO_PROP.get(binding.name) ?? binding.name;
1469+
const propertyName = REGISTRY.getMappedPropName(binding.name);
14851470
const prop = ts.factory.createElementAccessExpression(
14861471
elId,
14871472
ts.factory.createStringLiteral(propertyName),

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/GOLDEN_PARTIAL.js

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,86 @@
1+
/****************************************************************************************************
2+
* PARTIAL FILE: aria_dom_properties.js
3+
****************************************************************************************************/
4+
import { Component } from '@angular/core';
5+
import * as i0 from "@angular/core";
6+
export class MyComponent {
7+
constructor() {
8+
this.disabled = '';
9+
this.readonly = '';
10+
this.label = '';
11+
}
12+
}
13+
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
14+
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: `
15+
<input [attr.aria-disabled]="disabled" [aria-readonly]="readonly" [ariaLabel]="label">
16+
`, isInline: true });
17+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
18+
type: Component,
19+
args: [{
20+
template: `
21+
<input [attr.aria-disabled]="disabled" [aria-readonly]="readonly" [ariaLabel]="label">
22+
`,
23+
}]
24+
}] });
25+
26+
/****************************************************************************************************
27+
* PARTIAL FILE: aria_dom_properties.d.ts
28+
****************************************************************************************************/
29+
import * as i0 from "@angular/core";
30+
export declare class MyComponent {
31+
disabled: string;
32+
readonly: string;
33+
label: string;
34+
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
35+
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never, true, never>;
36+
}
37+
38+
/****************************************************************************************************
39+
* PARTIAL FILE: aria_properties.js
40+
****************************************************************************************************/
41+
import { Component, Directive } from '@angular/core';
42+
import * as i0 from "@angular/core";
43+
class MyDir {
44+
}
45+
MyDir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
46+
MyDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyDir, isStandalone: true, selector: "[myDir]", ngImport: i0 });
47+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyDir, decorators: [{
48+
type: Directive,
49+
args: [{ selector: '[myDir]' }]
50+
}] });
51+
export class MyComponent {
52+
constructor() {
53+
this.disabled = '';
54+
this.readonly = '';
55+
this.label = '';
56+
}
57+
}
58+
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
59+
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: `
60+
<input myDir [attr.aria-disabled]="disabled" [aria-readonly]="readonly" [ariaLabel]="label">
61+
`, isInline: true, dependencies: [{ kind: "directive", type: MyDir, selector: "[myDir]" }] });
62+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
63+
type: Component,
64+
args: [{
65+
template: `
66+
<input myDir [attr.aria-disabled]="disabled" [aria-readonly]="readonly" [ariaLabel]="label">
67+
`,
68+
imports: [MyDir],
69+
}]
70+
}] });
71+
72+
/****************************************************************************************************
73+
* PARTIAL FILE: aria_properties.d.ts
74+
****************************************************************************************************/
75+
import * as i0 from "@angular/core";
76+
export declare class MyComponent {
77+
disabled: string;
78+
readonly: string;
79+
label: string;
80+
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
81+
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never, true, never>;
82+
}
83+
184
/****************************************************************************************************
285
* PARTIAL FILE: bind.js
386
****************************************************************************************************/
@@ -349,26 +432,25 @@ import * as i0 from "@angular/core";
349432
export class ButtonDir {
350433
}
351434
ButtonDir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: ButtonDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
352-
ButtonDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: ButtonDir, isStandalone: false, selector: "button", inputs: { al: ["aria-label", "al"] }, ngImport: i0 });
435+
ButtonDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: ButtonDir, isStandalone: false, selector: "button", inputs: { label: "label" }, ngImport: i0 });
353436
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: ButtonDir, decorators: [{
354437
type: Directive,
355438
args: [{
356439
selector: 'button',
357-
standalone: false
440+
standalone: false,
358441
}]
359-
}], propDecorators: { al: [{
360-
type: Input,
361-
args: ['aria-label']
442+
}], propDecorators: { label: [{
443+
type: Input
362444
}] } });
363445
export class MyComponent {
364446
}
365447
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
366-
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: false, selector: "ng-component", ngImport: i0, template: '<button [title]="1" [attr.id]="2" [tabindex]="3" aria-label="{{1 + 3}}"></button>', isInline: true, dependencies: [{ kind: "directive", type: ButtonDir, selector: "button", inputs: ["aria-label"] }] });
448+
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: false, selector: "ng-component", ngImport: i0, template: '<button [title]="1" [attr.id]="2" [tabindex]="3" label="{{1 + 3}}"></button>', isInline: true, dependencies: [{ kind: "directive", type: ButtonDir, selector: "button", inputs: ["label"] }] });
367449
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
368450
type: Component,
369451
args: [{
370-
template: '<button [title]="1" [attr.id]="2" [tabindex]="3" aria-label="{{1 + 3}}"></button>',
371-
standalone: false
452+
template: '<button [title]="1" [attr.id]="2" [tabindex]="3" label="{{1 + 3}}"></button>',
453+
standalone: false,
372454
}]
373455
}] });
374456
export class MyMod {
@@ -386,9 +468,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
386468
****************************************************************************************************/
387469
import * as i0 from "@angular/core";
388470
export declare class ButtonDir {
389-
al: any;
471+
label: any;
390472
static ɵfac: i0.ɵɵFactoryDeclaration<ButtonDir, never>;
391-
static ɵdir: i0.ɵɵDirectiveDeclaration<ButtonDir, "button", never, { "al": { "alias": "aria-label"; "required": false; }; }, {}, never, never, false, never>;
473+
static ɵdir: i0.ɵɵDirectiveDeclaration<ButtonDir, "button", never, { "label": { "alias": "label"; "required": false; }; }, {}, never, never, false, never>;
392474
}
393475
export declare class MyComponent {
394476
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
@@ -408,26 +490,25 @@ import * as i0 from "@angular/core";
408490
export class ButtonDir {
409491
}
410492
ButtonDir.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: ButtonDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
411-
ButtonDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: ButtonDir, isStandalone: false, selector: "button", inputs: { al: ["aria-label", "al"] }, ngImport: i0 });
493+
ButtonDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: ButtonDir, isStandalone: false, selector: "button", inputs: { label: "label" }, ngImport: i0 });
412494
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: ButtonDir, decorators: [{
413495
type: Directive,
414496
args: [{
415497
selector: 'button',
416-
standalone: false
498+
standalone: false,
417499
}]
418-
}], propDecorators: { al: [{
419-
type: Input,
420-
args: ['aria-label']
500+
}], propDecorators: { label: [{
501+
type: Input
421502
}] } });
422503
export class MyComponent {
423504
}
424505
MyComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
425-
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: false, selector: "ng-component", ngImport: i0, template: '<button [title]="1" [id]="2" tabindex="{{0 + 3}}" aria-label="hello-{{1 + 3}}-{{2 + 3}}"></button>', isInline: true, dependencies: [{ kind: "directive", type: ButtonDir, selector: "button", inputs: ["aria-label"] }] });
506+
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: false, selector: "ng-component", ngImport: i0, template: '<button [title]="1" [id]="2" tabindex="{{0 + 3}}" label="hello-{{1 + 3}}-{{2 + 3}}"></button>', isInline: true, dependencies: [{ kind: "directive", type: ButtonDir, selector: "button", inputs: ["label"] }] });
426507
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
427508
type: Component,
428509
args: [{
429-
template: '<button [title]="1" [id]="2" tabindex="{{0 + 3}}" aria-label="hello-{{1 + 3}}-{{2 + 3}}"></button>',
430-
standalone: false
510+
template: '<button [title]="1" [id]="2" tabindex="{{0 + 3}}" label="hello-{{1 + 3}}-{{2 + 3}}"></button>',
511+
standalone: false,
431512
}]
432513
}] });
433514
export class MyMod {
@@ -445,9 +526,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
445526
****************************************************************************************************/
446527
import * as i0 from "@angular/core";
447528
export declare class ButtonDir {
448-
al: any;
529+
label: any;
449530
static ɵfac: i0.ɵɵFactoryDeclaration<ButtonDir, never>;
450-
static ɵdir: i0.ɵɵDirectiveDeclaration<ButtonDir, "button", never, { "al": { "alias": "aria-label"; "required": false; }; }, {}, never, never, false, never>;
531+
static ɵdir: i0.ɵɵDirectiveDeclaration<ButtonDir, "button", never, { "label": { "alias": "label"; "required": false; }; }, {}, never, never, false, never>;
451532
}
452533
export declare class MyComponent {
453534
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/property_bindings/TEST_CASES.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
11
{
22
"$schema": "../../test_case_schema.json",
33
"cases": [
4+
{
5+
"description": "should generate attribute instructions for DOM-only ARIA bindings",
6+
"inputFiles": [
7+
"aria_dom_properties.ts"
8+
],
9+
"expectations": [
10+
{
11+
"files": [
12+
"aria_dom_properties.js"
13+
]
14+
}
15+
]
16+
},
17+
{
18+
"description": "should generate ariaProperty instructions for ARIA bindings",
19+
"inputFiles": [
20+
"aria_properties.ts"
21+
],
22+
"expectations": [
23+
{
24+
"files": [
25+
"aria_properties.js"
26+
]
27+
}
28+
]
29+
},
430
{
531
"description": "should generate bind instruction",
632
"inputFiles": [
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
if (rf & 2) {
2+
$r3$.ɵɵattribute("aria-readonly", ctx.readonly)("aria-label", ctx.label)("aria-disabled", ctx.disabled);
3+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {Component} from '@angular/core';
2+
3+
@Component({
4+
template: `
5+
<input [attr.aria-disabled]="disabled" [aria-readonly]="readonly" [ariaLabel]="label">
6+
`,
7+
})
8+
export class MyComponent {
9+
disabled = '';
10+
readonly = '';
11+
label = '';
12+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
if (rf & 2) {
2+
$r3$.ɵɵariaProperty("aria-readonly", ctx.readonly)("ariaLabel", ctx.label);
3+
$r3$.ɵɵattribute("aria-disabled", ctx.disabled);
4+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {Component, Directive} from '@angular/core';
2+
3+
@Directive({selector: '[myDir]'})
4+
class MyDir {}
5+
6+
@Component({
7+
template: `
8+
<input myDir [attr.aria-disabled]="disabled" [aria-readonly]="readonly" [ariaLabel]="label">
9+
`,
10+
imports: [MyDir],
11+
})
12+
export class MyComponent {
13+
disabled = '';
14+
readonly = '';
15+
label = '';
16+
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
template: function MyComponent_Template(rf, ctx) {
22
33
if (rf & 2) {
4-
$r3$.ɵɵproperty("tabindex", $r3$.ɵɵinterpolate(0 + 3))("aria-label", $r3$.ɵɵinterpolate2("hello-", 1 + 3, "-", 2 + 3))("title", 1)("id", 2);
4+
$r3$.ɵɵproperty("tabindex", $r3$.ɵɵinterpolate(0 + 3))("label", $r3$.ɵɵinterpolate2("hello-", 1 + 3, "-", 2 + 3))("title", 1)("id", 2);
55
}
66
}
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
import {Component, Directive, Input, NgModule} from '@angular/core';
22

33
@Directive({
4-
selector: 'button',
5-
standalone: false
4+
selector: 'button',
5+
standalone: false,
66
})
77
export class ButtonDir {
8-
@Input('aria-label') al!: any;
8+
@Input() label!: any;
99
}
1010

11-
1211
@Component({
13-
template: '<button [title]="1" [id]="2" tabindex="{{0 + 3}}" aria-label="hello-{{1 + 3}}-{{2 + 3}}"></button>',
14-
standalone: false
12+
template:
13+
'<button [title]="1" [id]="2" tabindex="{{0 + 3}}" label="hello-{{1 + 3}}-{{2 + 3}}"></button>',
14+
standalone: false,
1515
})
16-
export class MyComponent {
17-
}
16+
export class MyComponent {}
1817

1918
@NgModule({declarations: [ButtonDir, MyComponent]})
20-
export class MyMod {
21-
}
19+
export class MyMod {}

0 commit comments

Comments
 (0)