Skip to content

Commit 865189e

Browse files
committed
fix(checkbox, radio): setting id to null causes invalid id for input
* In some situations, developers will change the `id` of a component dynamically. At some point if they set the `[id]` to `null` the input id will look like the following: `null-input`. * If id's are auto-generated they should be reflected in the `id` property of the component (after setting the id to `null` for example) * If developers are setting the `id` using a binding then the DOM Id for the host component won't be set at all (because the @input is triggered instead of the DOM property) Fixes #5394
1 parent f73cc97 commit 865189e

File tree

7 files changed

+114
-49
lines changed

7 files changed

+114
-49
lines changed

src/lib/checkbox/checkbox.spec.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,15 @@ describe('MdCheckbox', () => {
257257

258258
it('should preserve the user-provided id', () => {
259259
expect(checkboxNativeElement.id).toBe('simple-check');
260+
expect(inputElement.id).toBe('simple-check-input');
261+
});
262+
263+
it('should generate a unique id for the checkbox input if no id is set', () => {
264+
testComponent.checkboxId = null;
265+
fixture.detectChanges();
266+
267+
expect(checkboxInstance.inputId).toMatch(/md-checkbox-\d+/);
268+
expect(inputElement.id).toBe(`${checkboxInstance.id}-input`);
260269
});
261270

262271
it('should project the checkbox content into the label element', () => {
@@ -666,8 +675,8 @@ describe('MdCheckbox', () => {
666675
fixture.debugElement.queryAll(By.directive(MdCheckbox))
667676
.map(debugElement => debugElement.nativeElement.querySelector('input').id);
668677

669-
expect(firstId).toBeTruthy();
670-
expect(secondId).toBeTruthy();
678+
expect(firstId).toMatch(/md-checkbox-\d+-input/);
679+
expect(secondId).toMatch(/md-checkbox-\d+-input/);
671680
expect(firstId).not.toEqual(secondId);
672681
});
673682
});
@@ -824,7 +833,7 @@ describe('MdCheckbox', () => {
824833
template: `
825834
<div (click)="parentElementClicked = true" (keyup)="parentElementKeyedUp = true">
826835
<md-checkbox
827-
id="simple-check"
836+
[id]="checkboxId"
828837
[required]="isRequired"
829838
[labelPosition]="labelPos"
830839
[checked]="isChecked"
@@ -848,6 +857,7 @@ class SingleCheckbox {
848857
disableRipple: boolean = false;
849858
parentElementClicked: boolean = false;
850859
parentElementKeyedUp: boolean = false;
860+
checkboxId: string | null = 'simple-check';
851861
checkboxColor: string = 'primary';
852862
checkboxValue: string = 'single_checkbox';
853863

src/lib/checkbox/checkbox.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@ import {coerceBooleanProperty} from '@angular/cdk';
2626
import {FocusOrigin, FocusOriginMonitor, MdRipple, RippleRef} from '../core';
2727
import {mixinDisabled, CanDisable} from '../core/common-behaviors/disabled';
2828
import {CanColor, mixinColor} from '../core/common-behaviors/color';
29-
30-
31-
/** Monotonically increasing integer used to auto-generate unique ids for checkbox components. */
32-
let nextId = 0;
29+
import {CanId, mixinId} from '../core/common-behaviors/id';
3330

3431
/**
3532
* Provider Expression that allows md-checkbox to register as a ControlValueAccessor.
@@ -70,7 +67,8 @@ export class MdCheckboxChange {
7067
export class MdCheckboxBase {
7168
constructor(public _renderer: Renderer2, public _elementRef: ElementRef) {}
7269
}
73-
export const _MdCheckboxMixinBase = mixinColor(mixinDisabled(MdCheckboxBase), 'accent');
70+
export const _MdCheckboxMixinBase =
71+
mixinId(mixinColor(mixinDisabled(MdCheckboxBase), 'accent'), 'md-checkbox');
7472

7573

7674
/**
@@ -88,18 +86,19 @@ export const _MdCheckboxMixinBase = mixinColor(mixinDisabled(MdCheckboxBase), 'a
8886
styleUrls: ['checkbox.css'],
8987
host: {
9088
'class': 'mat-checkbox',
89+
'[id]': 'id',
9190
'[class.mat-checkbox-indeterminate]': 'indeterminate',
9291
'[class.mat-checkbox-checked]': 'checked',
9392
'[class.mat-checkbox-disabled]': 'disabled',
9493
'[class.mat-checkbox-label-before]': 'labelPosition == "before"',
9594
},
9695
providers: [MD_CHECKBOX_CONTROL_VALUE_ACCESSOR],
97-
inputs: ['disabled', 'color'],
96+
inputs: ['disabled', 'color', 'id'],
9897
encapsulation: ViewEncapsulation.None,
9998
changeDetection: ChangeDetectionStrategy.OnPush
10099
})
101100
export class MdCheckbox extends _MdCheckboxMixinBase
102-
implements ControlValueAccessor, AfterViewInit, OnDestroy, CanColor, CanDisable {
101+
implements ControlValueAccessor, AfterViewInit, OnDestroy, CanColor, CanDisable, CanId {
103102
/**
104103
* Attached to the aria-label attribute of the host element. In most cases, arial-labelledby will
105104
* take precedence so this may be omitted.
@@ -111,8 +110,8 @@ export class MdCheckbox extends _MdCheckboxMixinBase
111110
*/
112111
@Input('aria-labelledby') ariaLabelledby: string | null = null;
113112

114-
/** A unique id for the checkbox. If one is not supplied, it is auto-generated. */
115-
@Input() id: string = `md-checkbox-${++nextId}`;
113+
/** Returns the unique id for the visual hidden input. */
114+
get inputId(): string { return `${this.id}-input`; }
116115

117116
/** Whether the ripple effect on click should be disabled. */
118117
private _disableRipple: boolean;
@@ -122,11 +121,6 @@ export class MdCheckbox extends _MdCheckboxMixinBase
122121
get disableRipple(): boolean { return this._disableRipple; }
123122
set disableRipple(value) { this._disableRipple = coerceBooleanProperty(value); }
124123

125-
/** ID of the native input element inside `<md-checkbox>` */
126-
get inputId(): string {
127-
return `input-${this.id}`;
128-
}
129-
130124
private _required: boolean;
131125

132126
/** Whether the checkbox is required. */
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {mixinId} from './id';
2+
3+
describe('MixinId', () => {
4+
5+
it('should augment an existing class with an id property', () => {
6+
const classWithId = mixinId(BaseClass, 'mat-checkbox');
7+
const instance = new classWithId();
8+
9+
expect(instance.id)
10+
.toMatch(/mat-checkbox-\d+/, 'Expected the mixin to auto-generate a unique id by default.');
11+
12+
instance.id = 'custom-name';
13+
14+
expect(instance.id).toBe('custom-name', 'Expected the mixin to allow setting the id property');
15+
});
16+
17+
it('should not generate the same unique id multiple times', () => {
18+
const firstClassWithId = mixinId(BaseClass, 'mat-checkbox');
19+
const secondClassWithId = mixinId(BaseClass, 'mat-checkbox');
20+
21+
const firstClassInstance = new firstClassWithId();
22+
const secondClassInstance = new secondClassWithId();
23+
24+
expect(firstClassInstance.id).toBeTruthy();
25+
expect(secondClassInstance.id).toBeTruthy();
26+
27+
expect(firstClassInstance.id).not.toBe(secondClassInstance.id,
28+
'Expected the mixin to not generate the unique id multiple times');
29+
});
30+
31+
});
32+
33+
export class BaseClass {}

src/lib/core/common-behaviors/id.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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.io/license
7+
*/
8+
9+
import {Constructor} from './constructor';
10+
11+
/** Monotonically increasing integer used to auto-generate unique ids. */
12+
let nextUniqueId = 0;
13+
14+
/** @docs-private */
15+
export interface CanId {
16+
id: string;
17+
}
18+
19+
/**
20+
* Mixin to augment a directive with an `id` property.
21+
* The id property will fall back to auto-generated unique ids if developers don't specify one.
22+
*/
23+
export function mixinId<T extends Constructor<{}>>(base: T, componentName: string)
24+
: Constructor<CanId> & T {
25+
return class extends base {
26+
private _id: string;
27+
private _uniqueId = `${componentName}-${++nextUniqueId}`;
28+
29+
/** Unique id for the component. If one is not supplied, it is auto-generated. */
30+
get id(): string { return this._id || this._uniqueId; }
31+
set id(value: string) { this._id = value; }
32+
33+
constructor(...args: any[]) { super(...args); }
34+
};
35+
}

src/lib/radio/radio.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
import {coerceBooleanProperty} from '@angular/cdk';
3939
import {mixinDisabled, CanDisable} from '../core/common-behaviors/disabled';
4040
import {CanColor, mixinColor} from '../core/common-behaviors/color';
41+
import {CanId, mixinId} from '../core/common-behaviors/id';
4142

4243

4344
/**
@@ -51,8 +52,6 @@ export const MD_RADIO_GROUP_CONTROL_VALUE_ACCESSOR: any = {
5152
multi: true
5253
};
5354

54-
let _uniqueIdCounter = 0;
55-
5655
/** Change event object emitted by MdRadio and MdRadioGroup. */
5756
export class MdRadioChange {
5857
/** The MdRadioButton that emits the change event. */
@@ -65,7 +64,7 @@ export class MdRadioChange {
6564
// Boilerplate for applying mixins to MdRadioGroup.
6665
/** @docs-private */
6766
export class MdRadioGroupBase { }
68-
export const _MdRadioGroupMixinBase = mixinDisabled(MdRadioGroupBase);
67+
export const _MdRadioGroupMixinBase = mixinId(mixinDisabled(MdRadioGroupBase), 'md-radio-group');
6968

7069
/**
7170
* A group of radio buttons. May contain one or more `<md-radio-button>` elements.
@@ -76,11 +75,12 @@ export const _MdRadioGroupMixinBase = mixinDisabled(MdRadioGroupBase);
7675
host: {
7776
'role': 'radiogroup',
7877
'class': 'mat-radio-group',
78+
'[id]': 'id',
7979
},
8080
inputs: ['disabled'],
8181
})
8282
export class MdRadioGroup extends _MdRadioGroupMixinBase
83-
implements AfterContentInit, ControlValueAccessor, CanDisable {
83+
implements AfterContentInit, ControlValueAccessor, CanDisable, CanId {
8484
/**
8585
* Selected value for group. Should equal the value of the selected radio button if there *is*
8686
* a corresponding radio button with a matching value. If there is *not* such a corresponding
@@ -90,7 +90,7 @@ export class MdRadioGroup extends _MdRadioGroupMixinBase
9090
private _value: any = null;
9191

9292
/** The HTML name attribute applied to radio buttons in this group. */
93-
private _name: string = `md-radio-group-${_uniqueIdCounter++}`;
93+
private _name: string = this.id;
9494

9595
/** The currently selected radio button. Should match value. */
9696
private _selected: MdRadioButton | null = null;
@@ -303,7 +303,8 @@ export class MdRadioButtonBase {
303303
}
304304
// As per Material design specifications the selection control radio should use the accent color
305305
// palette by default. https://material.io/guidelines/components/selection-controls.html
306-
export const _MdRadioButtonMixinBase = mixinColor(MdRadioButtonBase, 'accent');
306+
export const _MdRadioButtonMixinBase =
307+
mixinId(mixinColor(MdRadioButtonBase, 'accent'), 'md-radio-button');
307308

308309
/**
309310
* A radio-button. May be inside of
@@ -313,7 +314,7 @@ export const _MdRadioButtonMixinBase = mixinColor(MdRadioButtonBase, 'accent');
313314
selector: 'md-radio-button, mat-radio-button',
314315
templateUrl: 'radio.html',
315316
styleUrls: ['radio.css'],
316-
inputs: ['color'],
317+
inputs: ['color', 'id'],
317318
encapsulation: ViewEncapsulation.None,
318319
host: {
319320
'class': 'mat-radio-button',
@@ -324,13 +325,10 @@ export const _MdRadioButtonMixinBase = mixinColor(MdRadioButtonBase, 'accent');
324325
changeDetection: ChangeDetectionStrategy.OnPush,
325326
})
326327
export class MdRadioButton extends _MdRadioButtonMixinBase
327-
implements OnInit, AfterViewInit, OnDestroy, CanColor {
328-
329-
/** The unique ID for the radio button. */
330-
@Input() id: string = `md-radio-${_uniqueIdCounter++}`;
328+
implements OnInit, AfterViewInit, OnDestroy, CanColor, CanId {
331329

332330
/** Analog to HTML 'name' attribute used to group radios for unique selection. */
333-
@Input() name: string;
331+
@Input() name: string = this.id;
334332

335333
/** Used to set the 'aria-label' attribute on the underlying input element. */
336334
@Input('aria-label') ariaLabel: string;
@@ -437,9 +435,7 @@ export class MdRadioButton extends _MdRadioButtonMixinBase
437435
radioGroup: MdRadioGroup;
438436

439437
/** ID of the native input element inside `<md-radio-button>` */
440-
get inputId(): string {
441-
return `${this.id}-input`;
442-
}
438+
get inputId(): string { return `${this.id}-input`; }
443439

444440
/** Whether this radio is checked. */
445441
private _checked: boolean = false;

src/lib/slide-toggle/slide-toggle.spec.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,18 +177,21 @@ describe('MdSlideToggle', () => {
177177
testComponent.slideId = 'myId';
178178
fixture.detectChanges();
179179

180-
expect(inputElement.id).toBe('myId-input');
180+
expect(slideToggleElement.id).toBe('myId');
181+
expect(inputElement.id).toBe(`${slideToggleElement.id}-input`);
181182

182183
testComponent.slideId = 'nextId';
183184
fixture.detectChanges();
184185

185-
expect(inputElement.id).toBe('nextId-input');
186+
expect(slideToggleElement.id).toBe('nextId');
187+
expect(inputElement.id).toBe(`${slideToggleElement.id}-input`);
186188

187189
testComponent.slideId = null;
188190
fixture.detectChanges();
189191

190-
// Once the id input is falsy, we use a default prefix with a incrementing unique number.
191-
expect(inputElement.id).toMatch(/md-slide-toggle-[0-9]+-input/g);
192+
// Once the id binding is set to null, the id property should auto-generate a unique id.
193+
expect(slideToggleElement.id).toMatch(/md-slide-toggle-\d+/);
194+
expect(inputElement.id).toBe(`${slideToggleElement.id}-input`);
192195
});
193196

194197
it('should forward the tabIndex to the underlying input', () => {

src/lib/slide-toggle/slide-toggle.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
3535
import {mixinDisabled, CanDisable} from '../core/common-behaviors/disabled';
3636
import {CanColor, mixinColor} from '../core/common-behaviors/color';
37+
import {mixinId, CanId} from '../core/common-behaviors/id';
3738

3839

3940
export const MD_SLIDE_TOGGLE_VALUE_ACCESSOR: any = {
@@ -48,42 +49,37 @@ export class MdSlideToggleChange {
4849
checked: boolean;
4950
}
5051

51-
// Increasing integer for generating unique ids for slide-toggle components.
52-
let nextId = 0;
53-
54-
55-
5652
// Boilerplate for applying mixins to MdSlideToggle.
5753
/** @docs-private */
5854
export class MdSlideToggleBase {
5955
constructor(public _renderer: Renderer2, public _elementRef: ElementRef) {}
6056
}
61-
export const _MdSlideToggleMixinBase = mixinColor(mixinDisabled(MdSlideToggleBase), 'accent');
57+
export const _MdSlideToggleMixinBase =
58+
mixinId(mixinColor(mixinDisabled(MdSlideToggleBase), 'accent'), 'md-slide-toggle');
6259

6360
/** Represents a slidable "switch" toggle that can be moved between on and off. */
6461
@Component({
6562
moduleId: module.id,
6663
selector: 'md-slide-toggle, mat-slide-toggle',
6764
host: {
6865
'class': 'mat-slide-toggle',
66+
'[id]': 'id',
6967
'[class.mat-checked]': 'checked',
7068
'[class.mat-disabled]': 'disabled',
7169
'[class.mat-slide-toggle-label-before]': 'labelPosition == "before"',
7270
},
7371
templateUrl: 'slide-toggle.html',
7472
styleUrls: ['slide-toggle.css'],
7573
providers: [MD_SLIDE_TOGGLE_VALUE_ACCESSOR],
76-
inputs: ['disabled', 'color'],
74+
inputs: ['disabled', 'color', 'id'],
7775
encapsulation: ViewEncapsulation.None,
7876
changeDetection: ChangeDetectionStrategy.OnPush
7977
})
8078
export class MdSlideToggle extends _MdSlideToggleMixinBase
81-
implements OnDestroy, AfterContentInit, ControlValueAccessor, CanDisable, CanColor {
79+
implements OnDestroy, AfterContentInit, ControlValueAccessor, CanDisable, CanColor, CanId {
8280
private onChange = (_: any) => {};
8381
private onTouched = () => {};
8482

85-
// A unique id for the slide-toggle. By default the id is auto-generated.
86-
private _uniqueId = `md-slide-toggle-${++nextId}`;
8783
private _checked: boolean = false;
8884
private _slideRenderer: SlideToggleRenderer;
8985
private _required: boolean = false;
@@ -95,8 +91,6 @@ export class MdSlideToggle extends _MdSlideToggleMixinBase
9591
/** Name value will be applied to the input element if present */
9692
@Input() name: string | null = null;
9793

98-
/** A unique id for the slide-toggle input. If none is supplied, it will be auto-generated. */
99-
@Input() id: string = this._uniqueId;
10094

10195
/** Used to specify the tabIndex value for the underlying input element. */
10296
@Input() tabIndex: number = 0;
@@ -124,7 +118,7 @@ export class MdSlideToggle extends _MdSlideToggleMixinBase
124118
@Output() change: EventEmitter<MdSlideToggleChange> = new EventEmitter<MdSlideToggleChange>();
125119

126120
/** Returns the unique id for the visual hidden input. */
127-
get inputId(): string { return `${this.id || this._uniqueId}-input`; }
121+
get inputId(): string { return `${this.id}-input`; }
128122

129123
/** Reference to the underlying input element. */
130124
@ViewChild('input') _inputElement: ElementRef;

0 commit comments

Comments
 (0)