Skip to content

Commit 4636a98

Browse files
devversionmmalerba
authored andcommitted
fix(checkbox, slide-toggle): no margin if content is projected (#12973)
* fix(checkbox, slide-toggle): no margin if content is projected Usually if the label of the checkbox or slide-toggle is empty, we remove the margin between label container and thumb/check because otherwise there would be too much spacing. Currently if developers use a component inside of the checkbox or slide-toggle in order to render the label, the margin is accidentally removed and the label looks misplaced. This is because the `cdkObserveContent` event runs outside of the `NgZone` and no change detection round _checks_ the checkbox or slide-toggle. Fixes #4720 * Add tests
1 parent 4e15ba9 commit 4636a98

File tree

4 files changed

+99
-14
lines changed

4 files changed

+99
-14
lines changed

src/lib/checkbox/checkbox.spec.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import {MutationObserverFactory} from '@angular/cdk/observers';
1919
describe('MatCheckbox', () => {
2020
let fixture: ComponentFixture<any>;
2121

22-
function createComponent<T>(componentType: Type<T>): ComponentFixture<T> {
22+
function createComponent<T>(componentType: Type<T>, extraDeclarations: Type<any>[] = []) {
2323
TestBed.configureTestingModule({
2424
imports: [MatCheckboxModule, FormsModule, ReactiveFormsModule],
25-
declarations: [componentType],
25+
declarations: [componentType, ...extraDeclarations],
2626
}).compileComponents();
2727

2828
return TestBed.createComponent<T>(componentType);
@@ -1104,7 +1104,37 @@ describe('MatCheckbox', () => {
11041104
fixture.detectChanges();
11051105
expect(checkboxInnerContainer.querySelector('input')!.hasAttribute('value')).toBe(false);
11061106
});
1107+
});
1108+
1109+
describe('label margin', () => {
1110+
it('should properly update margin if label content is projected', () => {
1111+
const mutationCallbacks: Function[] = [];
1112+
1113+
TestBed.configureTestingModule({
1114+
providers: [
1115+
{provide: MutationObserverFactory, useValue: {
1116+
create: (callback: Function) => {
1117+
mutationCallbacks.push(callback);
1118+
return {observe: () => {}, disconnect: () => {}};
1119+
}
1120+
}}
1121+
]
1122+
});
11071123

1124+
fixture = createComponent(CheckboxWithProjectedLabel, [TextBindingComponent]);
1125+
fixture.detectChanges();
1126+
1127+
const checkboxInnerContainer = fixture.debugElement
1128+
.query(By.css('.mat-checkbox-inner-container')).nativeElement;
1129+
1130+
// Do not run the change detection for the fixture manually because we want to verify
1131+
// that the checkbox properly toggles the margin class even if the observe content output
1132+
// fires outside of the zone.
1133+
mutationCallbacks.forEach(callback => callback());
1134+
1135+
expect(checkboxInnerContainer.classList).not
1136+
.toContain('mat-checkbox-inner-container-no-side-margin');
1137+
});
11081138
});
11091139
});
11101140

@@ -1238,3 +1268,18 @@ class CheckboxWithoutLabel {
12381268
template: `<mat-checkbox tabindex="5"></mat-checkbox>`
12391269
})
12401270
class CheckboxWithTabindexAttr {}
1271+
1272+
/** Test component that uses another component for its label. */
1273+
@Component({
1274+
template: `<mat-checkbox><some-text></some-text></mat-checkbox>`
1275+
})
1276+
class CheckboxWithProjectedLabel {}
1277+
1278+
/** Component that renders some text through a binding. */
1279+
@Component({
1280+
selector: 'some-text',
1281+
template: '<span>{{text}}</span>'
1282+
})
1283+
class TextBindingComponent {
1284+
text: string = 'Some text';
1285+
}

src/lib/checkbox/checkbox.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,12 @@ export class MatCheckbox extends _MatCheckboxMixinBase implements ControlValueAc
282282

283283
/** Method being called whenever the label text changes. */
284284
_onLabelTextChange() {
285-
// This method is getting called whenever the label of the checkbox changes.
286-
// Since the checkbox uses the OnPush strategy we need to notify it about the change
287-
// that has been recognized by the cdkObserveContent directive.
288-
this._changeDetectorRef.markForCheck();
285+
// Since the event of the `cdkObserveContent` directive runs outside of the zone, the checkbox
286+
// component will be only marked for check, but no actual change detection runs automatically.
287+
// Instead of going back into the zone in order to trigger a change detection which causes
288+
// *all* components to be checked (if explicitly marked or not using OnPush), we only trigger
289+
// an explicit change detection for the checkbox view and it's children.
290+
this._changeDetectorRef.detectChanges();
289291
}
290292

291293
// Implemented as part of ControlValueAccessor.

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

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ describe('MatSlideToggle without forms', () => {
2929
declarations: [
3030
SlideToggleBasic,
3131
SlideToggleWithTabindexAttr,
32-
SlideToggleWithoutLabel
32+
SlideToggleWithoutLabel,
33+
SlideToggleProjectedLabel,
34+
TextBindingComponent,
3335
],
3436
providers: [
3537
{provide: HAMMER_GESTURE_CONFIG, useFactory: () => gestureConfig = new TestGestureConfig()},
@@ -657,7 +659,6 @@ describe('MatSlideToggle without forms', () => {
657659
describe('without label', () => {
658660
let fixture: ComponentFixture<SlideToggleWithoutLabel>;
659661
let testComponent: SlideToggleWithoutLabel;
660-
let slideToggleElement: HTMLElement;
661662
let slideToggleBarElement: HTMLElement;
662663

663664
beforeEach(() => {
@@ -666,7 +667,6 @@ describe('MatSlideToggle without forms', () => {
666667
const slideToggleDebugEl = fixture.debugElement.query(By.directive(MatSlideToggle));
667668

668669
testComponent = fixture.componentInstance;
669-
slideToggleElement = slideToggleDebugEl.nativeElement;
670670
slideToggleBarElement = slideToggleDebugEl
671671
.query(By.css('.mat-slide-toggle-bar')).nativeElement;
672672
});
@@ -697,10 +697,33 @@ describe('MatSlideToggle without forms', () => {
697697
flushMutationObserver();
698698
fixture.detectChanges();
699699

700-
expect(slideToggleElement.classList)
700+
expect(slideToggleBarElement.classList)
701701
.not.toContain('mat-slide-toggle-bar-no-side-margin');
702702
}));
703703
});
704+
705+
describe('label margin', () => {
706+
let fixture: ComponentFixture<SlideToggleProjectedLabel>;
707+
let slideToggleBarElement: HTMLElement;
708+
709+
beforeEach(() => {
710+
fixture = TestBed.createComponent(SlideToggleProjectedLabel);
711+
slideToggleBarElement = fixture.debugElement
712+
.query(By.css('.mat-slide-toggle-bar')).nativeElement;
713+
714+
fixture.detectChanges();
715+
});
716+
717+
it('should properly update margin if label content is projected', () => {
718+
// Do not run the change detection for the fixture manually because we want to verify
719+
// that the slide-toggle properly toggles the margin class even if the observe content
720+
// output fires outside of the zone.
721+
flushMutationObserver();
722+
723+
expect(slideToggleBarElement.classList).not
724+
.toContain('mat-slide-toggle-bar-no-side-margin');
725+
});
726+
});
704727
});
705728

706729
describe('MatSlideToggle with forms', () => {
@@ -1087,3 +1110,16 @@ class SlideToggleWithModelAndChangeEvent {
10871110
checked: boolean;
10881111
onChange: () => void = () => {};
10891112
}
1113+
1114+
@Component({
1115+
template: `<mat-slide-toggle><some-text></some-text></mat-slide-toggle>`
1116+
})
1117+
class SlideToggleProjectedLabel {}
1118+
1119+
@Component({
1120+
selector: 'some-text',
1121+
template: `<span>{{text}}</span>`
1122+
})
1123+
class TextBindingComponent {
1124+
text: string = 'Some text';
1125+
}

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,11 @@ export class MatSlideToggle extends _MatSlideToggleMixinBase implements OnDestro
358358

359359
/** Method being called whenever the label text changes. */
360360
_onLabelTextChange() {
361-
// This method is getting called whenever the label of the slide-toggle changes.
362-
// Since the slide-toggle uses the OnPush strategy we need to notify it about the change
363-
// that has been recognized by the cdkObserveContent directive.
364-
this._changeDetectorRef.markForCheck();
361+
// Since the event of the `cdkObserveContent` directive runs outside of the zone, the
362+
// slide-toggle component will be only marked for check, but no actual change detection runs
363+
// automatically. Instead of going back into the zone in order to trigger a change detection
364+
// which causes *all* components to be checked (if explicitly marked or not using OnPush),
365+
// we only trigger an explicit change detection for the slide-toggle view and it's children.
366+
this._changeDetectorRef.detectChanges();
365367
}
366368
}

0 commit comments

Comments
 (0)