Skip to content

Commit f008045

Browse files
leonsenftthePunderWoman
authored andcommitted
fix(core): do not rename ARIA property bindings to attributes (#63925)
#62630 made it so that all ARIA property bindings would write to their corresponding attribute instead. The primary motivation for this change was to ensure that ARIA attributes were always rendered correctly on the server, where the emulated DOM may not correctly reflect ARIA properties as attributes. Furthermore, this change added support for binding to ARIA attributes using the property binding syntax (e.g. `[aria-label]`). Unfortunately, #62630 relied on the incorrect assumptions that an ARIA property name could be converted to its attribute name (without hardcoding the conversion), and that the value of an ARIA property matched its corresponding attribute. For example, the `ariaLabelledByElements` property's value is an array of DOM elements, while the corresponding `aria-labelledby` attribute's value is a string containing the IDs of the DOM elements. This partially reverts #62630 so that only property bindings with ARIA attribute names (begin with `aria-`) are converted to attribute bindings. * `[ariaLabel]` will revert to binding to the `ariaLabel` property. * `[aria-label]` will continue binding to the `aria-label` attribute. Note the only difference between `[aria-label]` and `[attr.aria-label]` is that the former will attempt to bind to inputs of the same name while the latter will not. PR Close #63925
1 parent f1be5ae commit f008045

File tree

12 files changed

+131
-91
lines changed

12 files changed

+131
-91
lines changed

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,41 @@
1+
/****************************************************************************************************
2+
* PARTIAL FILE: aria_bindings.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", host: { properties: { "attr.aria-disabled": "disabled", "aria-readonly": "readonly", "ariaLabel": "label" } }, ngImport: i0, template: ``, isInline: true });
15+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
16+
type: Component,
17+
args: [{
18+
template: ``,
19+
host: {
20+
'[attr.aria-disabled]': 'disabled',
21+
'[aria-readonly]': 'readonly',
22+
'[ariaLabel]': 'label',
23+
},
24+
}]
25+
}] });
26+
27+
/****************************************************************************************************
28+
* PARTIAL FILE: aria_bindings.d.ts
29+
****************************************************************************************************/
30+
import * as i0 from "@angular/core";
31+
export declare class MyComponent {
32+
disabled: string;
33+
readonly: string;
34+
label: string;
35+
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
36+
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never, true, never>;
37+
}
38+
139
/****************************************************************************************************
240
* PARTIAL FILE: host_bindings.js
341
****************************************************************************************************/

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
{
22
"$schema": "../../test_case_schema.json",
33
"cases": [
4+
{
5+
"description": "should support host bindings to ARIA attributes",
6+
"inputFiles": ["aria_bindings.ts"],
7+
"expectations": [
8+
{
9+
"failureMessage": "Invalid host binding code",
10+
"files": ["aria_bindings.js"]
11+
}
12+
]
13+
},
414
{
515
"description": "should support host bindings",
616
"inputFiles": ["host_bindings.ts"],
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
hostBindings: function MyComponent_HostBindings(rf, ctx) {
2+
if (rf & 2) {
3+
$r3$.ɵɵdomProperty("ariaLabel", ctx.label);
4+
$r3$.ɵɵattribute("aria-disabled", ctx.disabled)("aria-readonly", ctx.readonly);
5+
}
6+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {Component} from '@angular/core';
2+
3+
@Component({
4+
template: ``,
5+
host: {
6+
'[attr.aria-disabled]': 'disabled',
7+
'[aria-readonly]': 'readonly',
8+
'[ariaLabel]': 'label',
9+
},
10+
})
11+
export class MyComponent {
12+
disabled = '';
13+
readonly = '';
14+
label = '';
15+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
if (rf & 2) {
2-
$r3$.ɵɵattribute("aria-readonly", ctx.readonly)("aria-label", ctx.label)("aria-disabled", ctx.disabled);
2+
$r3$.ɵɵdomProperty("ariaLabel", ctx.label);
3+
$r3$.ɵɵattribute("aria-disabled", ctx.disabled)("aria-readonly", ctx.readonly);
34
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
if (rf & 2) {
2-
$r3$.ɵɵariaProperty("aria-readonly", ctx.readonly)("ariaLabel", ctx.label);
2+
$r3$.ɵɵariaProperty("aria-readonly", ctx.readonly);
3+
$r3$.ɵɵproperty("ariaLabel", ctx.label);
34
$r3$.ɵɵattribute("aria-disabled", ctx.disabled);
45
}

packages/compiler/src/template/pipeline/src/phases/binding_specialization.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import {splitNsName} from '../../../../ml_parser/tags';
1010
import * as o from '../../../../output/output_ast';
1111
import * as ir from '../../ir';
12-
import {CompilationJob, CompilationJobKind} from '../compilation';
12+
import {CompilationJob, CompilationJobKind, TemplateCompilationMode} from '../compilation';
13+
import {isAriaAttribute} from '../util/attributes';
1314

1415
/**
1516
* Looks up an element in the given map by xref ID.
@@ -95,7 +96,26 @@ export function specializeBindings(job: CompilationJob): void {
9596
break;
9697
case ir.BindingKind.Property:
9798
case ir.BindingKind.LegacyAnimation:
98-
if (job.kind === CompilationJobKind.Host) {
99+
// Convert a property binding targeting an ARIA attribute (e.g. [aria-label]) into an
100+
// attribute binding when we know it can't also target an input. Note that a `Host` job is
101+
// always `DomOnly`, so this condition must be checked first.
102+
if (job.mode === TemplateCompilationMode.DomOnly && isAriaAttribute(op.name)) {
103+
ir.OpList.replace<ir.UpdateOp>(
104+
op,
105+
ir.createAttributeOp(
106+
op.target,
107+
/* namespace= */ null,
108+
op.name,
109+
op.expression,
110+
op.securityContext,
111+
/* isTextAttribute= */ false,
112+
op.isStructuralTemplateAttribute,
113+
op.templateKind,
114+
op.i18nMessage,
115+
op.sourceSpan,
116+
),
117+
);
118+
} else if (job.kind === CompilationJobKind.Host) {
99119
ir.OpList.replace<ir.UpdateOp>(
100120
op,
101121
ir.createDomPropertyOp(
@@ -124,7 +144,6 @@ export function specializeBindings(job: CompilationJob): void {
124144
),
125145
);
126146
}
127-
128147
break;
129148
case ir.BindingKind.TwoWayProperty:
130149
if (!(op.expression instanceof o.Expression)) {

packages/compiler/src/template/pipeline/src/phases/reify.ts

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ import {
1616
type CompilationUnit,
1717
} from '../compilation';
1818
import * as ng from '../instruction';
19-
20-
const ARIA_PREFIX = 'aria';
19+
import {isAriaAttribute} from '../util/attributes';
2120

2221
/**
2322
* Map of target resolvers for event listeners.
@@ -687,35 +686,6 @@ function reifyUpdateOperations(unit: CompilationUnit, ops: ir.OpList<ir.UpdateOp
687686
}
688687
}
689688

690-
/**
691-
* Converts an ARIA property name to its corresponding attribute name, if necessary.
692-
*
693-
* For example, converts `ariaLabel` to `aria-label`.
694-
*
695-
* https://www.w3.org/TR/wai-aria-1.2/#accessibilityroleandproperties-correspondence
696-
*
697-
* This must be kept in sync with the the function of the same name in
698-
* packages/core/src/render3/instructions/aria_property.ts.
699-
*
700-
* @param name A property name that starts with `aria`.
701-
* @returns The corresponding attribute name.
702-
*/
703-
function ariaAttrName(name: string): string {
704-
return name.charAt(ARIA_PREFIX.length) !== '-'
705-
? ARIA_PREFIX + '-' + name.slice(ARIA_PREFIX.length).toLowerCase()
706-
: name; // Property already has attribute name.
707-
}
708-
709-
/**
710-
* Returns whether `name` is an ARIA property (or attribute) name.
711-
*
712-
* This is a heuristic based on whether name begins with and is longer than `aria`. For example,
713-
* this returns true for both `ariaLabel` and `aria-label`.
714-
*/
715-
function isAriaProperty(name: string): boolean {
716-
return name.startsWith(ARIA_PREFIX) && name.length > ARIA_PREFIX.length;
717-
}
718-
719689
/**
720690
* Reifies a DOM property binding operation.
721691
*
@@ -726,14 +696,12 @@ function isAriaProperty(name: string): boolean {
726696
* @returns A statement to update the property at runtime.
727697
*/
728698
function reifyDomProperty(op: ir.DomPropertyOp | ir.PropertyOp): ir.UpdateOp {
729-
return isAriaProperty(op.name)
730-
? ng.attribute(ariaAttrName(op.name), op.expression, null, null, op.sourceSpan)
731-
: ng.domProperty(
732-
DOM_PROPERTY_REMAPPING.get(op.name) ?? op.name,
733-
op.expression,
734-
op.sanitizer,
735-
op.sourceSpan,
736-
);
699+
return ng.domProperty(
700+
DOM_PROPERTY_REMAPPING.get(op.name) ?? op.name,
701+
op.expression,
702+
op.sanitizer,
703+
op.sourceSpan,
704+
);
737705
}
738706

739707
/**
@@ -746,7 +714,7 @@ function reifyDomProperty(op: ir.DomPropertyOp | ir.PropertyOp): ir.UpdateOp {
746714
* @returns A statement to update the property at runtime.
747715
*/
748716
function reifyProperty(op: ir.PropertyOp): ir.UpdateOp {
749-
return isAriaProperty(op.name)
717+
return isAriaAttribute(op.name)
750718
? ng.ariaProperty(op.name, op.expression, op.sourceSpan)
751719
: ng.property(op.name, op.expression, op.sanitizer, op.sourceSpan);
752720
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
const ARIA_PREFIX = 'aria-';
10+
11+
/**
12+
* Returns whether `name` is an ARIA attribute name.
13+
*
14+
* This is a heuristic based on whether name begins with and is longer than `aria-`.
15+
*/
16+
export function isAriaAttribute(name: string): boolean {
17+
return name.startsWith(ARIA_PREFIX) && name.length > ARIA_PREFIX.length;
18+
}

packages/core/src/render3/instructions/aria_property.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,13 @@ import {
2121
storePropertyBindingMetadata,
2222
} from './shared';
2323

24-
const ARIA_PREFIX = 'aria';
25-
2624
/**
27-
* Update an ARIA attribute by either its attribute or property name on a selected element.
25+
* Update an ARIA attribute on a selected element.
2826
*
29-
* If the property name also exists as an input property on any of the element's directives, those
30-
* inputs will be set instead of the element property.
27+
* If the attribute name also exists as an input property on any of the element's directives, those
28+
* inputs will be set instead of the element attribute.
3129
*
32-
* @param name Name of the ARIA attribute or property (beginning with `aria`).
30+
* @param name Name of the ARIA attribute (beginning with `aria-`).
3331
* @param value New value to write.
3432
* @returns This function returns itself so that it may be chained.
3533
*
@@ -49,30 +47,10 @@ export function ɵɵariaProperty<T>(name: string, value: T): typeof ɵɵariaProp
4947
} else {
5048
ngDevMode && assertTNodeType(tNode, TNodeType.Element);
5149
const element = getNativeByTNode(tNode, lView) as RElement;
52-
const attributeName = ariaAttrName(name);
53-
setElementAttribute(lView[RENDERER], element, null, tNode.value, attributeName, value, null);
50+
setElementAttribute(lView[RENDERER], element, null, tNode.value, name, value, null);
5451
}
5552

5653
ngDevMode && storePropertyBindingMetadata(tView.data, tNode, name, bindingIndex);
5754
}
5855
return ɵɵariaProperty;
5956
}
60-
61-
/**
62-
* Converts an ARIA property name to its corresponding attribute name, if necessary.
63-
*
64-
* For example, converts `ariaLabel` to `aria-label`.
65-
*
66-
* https://www.w3.org/TR/wai-aria-1.2/#accessibilityroleandproperties-correspondence
67-
*
68-
* This must be kept in sync with the the function of the same name in
69-
* packages/compiler/src/template/pipeline/src/phases/reify.ts
70-
*
71-
* @param name A property name that starts with `aria`.
72-
* @returns The corresponding attribute name.
73-
*/
74-
function ariaAttrName(name: string): string {
75-
return name.charAt(ARIA_PREFIX.length) !== '-'
76-
? ARIA_PREFIX + '-' + name.slice(ARIA_PREFIX.length).toLowerCase()
77-
: name; // Property already has attribute name.
78-
}

0 commit comments

Comments
 (0)