Skip to content
This repository was archived by the owner on Sep 5, 2024. It is now read-only.

Commit 4d36fd2

Browse files
committed
fix(checkbox): handle links in transcluded label in an a11y-friendly way
- JAWS and Voiceover should properly handle links in md-checkbox labels - Wrapping the checkbox in a `md-input-container` is required - for using links in checkbox labels due to layout considerations - Add new demos for different types of a11y checkbox labels - don't toggle the checkbox on link actions - don't output aria warnings when using `aria-labelledby` - require `md-input-container` when using links in checkbox labels Fixes #11134. BREAKING CHANGE: If you've created a custom solution to style links within `md-checkbox` labels, then you may need to remove or change that code now. This is because we automatically detect `<a>` tags in these labels and re-render them in an accessible way.
1 parent 96e4f1c commit 4d36fd2

File tree

6 files changed

+121
-4
lines changed

6 files changed

+121
-4
lines changed

src/components/checkbox/checkbox.js

+29-3
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ function MdCheckboxDirective(inputDirective, $mdAria, $mdConstant, $mdTheming, $
100100
var containerCtrl = ctrls[0];
101101
var ngModelCtrl = ctrls[1] || $mdUtil.fakeNgModel();
102102
var formCtrl = ctrls[2];
103+
var labelHasLink = element.find('a').length > 0;
104+
105+
// The original component structure is not accessible when the checkbox's label contains a link.
106+
// In order to keep backwards compatibility, we're only changing the structure of the component
107+
// when we detect a link within the label. Using a span after the md-checkbox and attaching it
108+
// via aria-labelledby allows screen readers to find and work with the link within the label.
109+
if (labelHasLink) {
110+
var labelId = 'label-' + $mdUtil.nextUid();
111+
attr.$set('aria-labelledby', labelId);
112+
113+
var label = element.children()[1];
114+
label.remove();
115+
label.removeAttribute('ng-transclude');
116+
label.className = 'md-checkbox-link-label';
117+
label.setAttribute('id', labelId);
118+
element.after(label);
119+
// Make sure that clicking on the label still causes the checkbox to be toggled, when appropriate.
120+
var externalLabel = element.next();
121+
externalLabel.on('click', listener);
122+
}
103123

104124
if (containerCtrl) {
105125
var isErrorGetter = containerCtrl.isErrorGetter || function() {
@@ -136,7 +156,11 @@ function MdCheckboxDirective(inputDirective, $mdAria, $mdConstant, $mdTheming, $
136156
false: attr.tabindex
137157
});
138158

139-
$mdAria.expectWithText(element, 'aria-label');
159+
// Don't emit a warning when the label has a link within it. In that case we'll use
160+
// aria-labelledby to point to another span that should be read as the label.
161+
if (!labelHasLink) {
162+
$mdAria.expectWithText(element, 'aria-label');
163+
}
140164

141165
// Reuse the original input[type=checkbox] directive from AngularJS core.
142166
// This is a bit hacky as we need our own event listener and own render
@@ -201,8 +225,10 @@ function MdCheckboxDirective(inputDirective, $mdAria, $mdConstant, $mdTheming, $
201225

202226
function listener(ev) {
203227
// skipToggle boolean is used by the switch directive to prevent the click event
204-
// when releasing the drag. There will be always a click if releasing the drag over the checkbox
205-
if (element[0].hasAttribute('disabled') || scope.skipToggle) {
228+
// when releasing the drag. There will be always a click if releasing the drag over the checkbox.
229+
// If the click came from a link in the checkbox, don't toggle the value.
230+
// We want the link to be opened without changing the value in this case.
231+
if (element[0].hasAttribute('disabled') || scope.skipToggle || ev.target.tagName === 'A') {
206232
return;
207233
}
208234

src/components/checkbox/checkbox.scss

+17-1
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,20 @@ md-checkbox {
9393
}
9494

9595
}
96-
}
96+
}
97+
md-input-container .md-checkbox-link-label {
98+
box-sizing: border-box;
99+
position: relative;
100+
display: inline-block;
101+
vertical-align: middle;
102+
white-space: normal;
103+
user-select: text;
104+
cursor: pointer;
105+
// The span is actually after the checkbox in the DOM, but we need it to line up, so we move it up
106+
// while not introducing any breaking changes to existing styles.
107+
top: -21px;
108+
109+
// In this mode, the checkbox's width needs to be factored in as well.
110+
@include rtl(margin-left, $checkbox-text-margin - $checkbox-width, 0);
111+
@include rtl(margin-right, 0, $checkbox-text-margin - $checkbox-width);
112+
}

src/components/checkbox/checkbox.spec.js

+15
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ describe('mdCheckbox', function() {
4141
expect(checkboxElement.attr('aria-label')).toBe('Some text');
4242
});
4343

44+
it('should handle text content that contains a link', function() {
45+
var element = compileAndLink(
46+
'<md-input-container>' +
47+
'<md-checkbox ng-model="blue">I agree to the <a href="/license">license</a>.</md-checkbox>' +
48+
'</md-input-container>');
49+
50+
var checkboxElement = element.find('md-checkbox').eq(0);
51+
expect(checkboxElement.attr('aria-labelledby')).toContain('label-');
52+
var labelElement = element.children()[1];
53+
expect(labelElement.getAttribute('id')).toContain('label-');
54+
expect(labelElement.innerHTML).toContain('I agree to the ');
55+
var linkElement = element.find('A').eq(0);
56+
expect(linkElement[0].innerHTML).toBe('license');
57+
});
58+
4459
it('should set checked css class and aria-checked attributes', function() {
4560
var element = compileAndLink(
4661
'<div>' +
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<div ng-controller="AppCtrl" class="md-padding" ng-cloak>
2+
<div>
3+
<fieldset class="standard">
4+
<legend>Using Different Layouts and Labels</legend>
5+
<div layout="column">
6+
<div class="md-dense" layout="column">
7+
<md-checkbox ng-model="data.cb1">
8+
Default Checkbox and Label
9+
</md-checkbox>
10+
<md-checkbox ng-model="data.cb2">
11+
Dynamic Label: {{data.cb2 ? 'Checked' : 'Unchecked'}}
12+
</md-checkbox>
13+
</div>
14+
<div layout="row" layout-align="start center" class="md-dense">
15+
<!-- Extra work is needed by the developer to make this work, including a11y. -->
16+
<label for="label-in-front" ng-click="data.cb3 = !data.cb3"
17+
aria-hidden="true" tabindex="-1">
18+
Label in Front
19+
</label>
20+
<md-checkbox ng-model="data.cb3" id="label-in-front"
21+
aria-label="Label in Front">
22+
</md-checkbox>
23+
</div>
24+
<md-input-container>
25+
<md-checkbox ng-model="data.cb4">
26+
Checkbox in an md-input-container
27+
</md-checkbox>
28+
</md-input-container>
29+
<md-subheader>Checkbox with an accessible link in the label</md-subheader>
30+
<md-input-container>
31+
<md-checkbox ng-model="data.cb5">
32+
I agree to the <a href="/license">license</a>.
33+
</md-checkbox>
34+
</md-input-container>
35+
</div>
36+
</fieldset>
37+
</div>
38+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
angular.module('checkboxDemo1', ['ngMaterial'])
2+
3+
.controller('AppCtrl', function($scope) {
4+
$scope.data = {};
5+
$scope.data.cb1 = true;
6+
$scope.data.cb2 = true;
7+
$scope.data.cb3 = false;
8+
$scope.data.cb4 = false;
9+
$scope.data.cb5 = false;
10+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
fieldset.standard {
2+
border: 1px solid;
3+
}
4+
legend {
5+
color: #3F51B5;
6+
}
7+
label {
8+
cursor: pointer;
9+
margin-right: 10px;
10+
user-select: none;
11+
height: 16px;
12+
}

0 commit comments

Comments
 (0)