Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/material/checkbox/checkbox.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@
Avoid putting a click handler on the <label/> to fix duplicate navigation stop on Talk Back
(#14385). Putting a click handler on the <label/> caused this bug because the browser produced
an unnecessary accessibility tree node.

The <label/> is omitted entirely when no label content is projected so accessibility tooling
does not flag an empty <label/> in the DOM (see #33230).
-->
<label class="mdc-label" #label [for]="inputId">
<ng-content></ng-content>
</label>
@if (_hasLabel()) {
<label class="mdc-label" #label [for]="inputId">
<ng-content></ng-content>
</label>
}
</div>
21 changes: 21 additions & 0 deletions src/material/checkbox/checkbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,27 @@ describe('MatCheckbox', () => {
fixture.detectChanges();
expect(checkboxInnerContainer.querySelector('input')!.hasAttribute('value')).toBe(false);
});

it('should not render an empty label element when no label is provided', () => {
// Run change detection twice: the first pass instantiates the view and projects the
// (empty) content, the second flushes the signal update from `ngAfterContentInit` that
// hides the now-empty `<label/>` element.
fixture.detectChanges();
fixture.detectChanges();
expect(checkboxInnerContainer.querySelector('label')).toBeNull();
});

it('should render a label element when label content is provided', () => {
const labeledFixture = TestBed.createComponent(CheckboxWithoutLabel);
labeledFixture.componentInstance.label = 'Has a label';
labeledFixture.detectChanges();

const innerContainer = labeledFixture.debugElement
.query(By.directive(MatCheckbox))!
.query(By.css('.mdc-form-field'))!.nativeElement as HTMLElement;
expect(innerContainer.querySelector('label')).not.toBeNull();
expect(innerContainer.querySelector('label')!.textContent!.trim()).toBe('Has a label');
});
});
});

Expand Down
60 changes: 56 additions & 4 deletions src/material/checkbox/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import {_IdGenerator, FocusableOption} from '@angular/cdk/a11y';
import {
AfterContentInit,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Expand Down Expand Up @@ -108,7 +109,13 @@ export class MatCheckboxChange {
imports: [MatRipple, _MatInternalFormField],
})
export class MatCheckbox
implements AfterViewInit, OnChanges, ControlValueAccessor, Validator, FocusableOption
implements
AfterContentInit,
AfterViewInit,
OnChanges,
ControlValueAccessor,
Validator,
FocusableOption
{
_elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private _changeDetectorRef = inject(ChangeDetectorRef);
Expand Down Expand Up @@ -207,8 +214,15 @@ export class MatCheckbox
/** The native `<input type="checkbox">` element */
@ViewChild('input') _inputElement!: ElementRef<HTMLInputElement>;

/** The native `<label>` element */
@ViewChild('label') _labelElement!: ElementRef<HTMLInputElement>;
/** The native `<label>` element. Only present when label content has been projected. */
@ViewChild('label') _labelElement?: ElementRef<HTMLLabelElement>;

/**
* Whether the checkbox has any projected label content. Drives whether the `<label/>` element
* is rendered. Avoids producing an empty `<label/>` that would be flagged by accessibility
* tooling (#33230).
*/
protected readonly _hasLabel = signal(true);

/** Tabindex for the checkbox. */
@Input({transform: (value: unknown) => (value == null ? undefined : numberAttribute(value))})
Expand Down Expand Up @@ -256,10 +270,47 @@ export class MatCheckbox
}
}

ngAfterContentInit() {
// Avoid producing an empty `<label/>` in the DOM which would otherwise be flagged by
// accessibility tooling (#33230). Content projection has resolved by this point so we can
// inspect the host's projected children to decide whether to keep the `<label/>` rendered.
this._hasLabel.set(this._hasProjectedLabelContent());
this._changeDetectorRef.markForCheck();
}

ngAfterViewInit() {
this._syncIndeterminate(this.indeterminate);
}

/**
* Returns whether any non-whitespace content was supplied as projected content to the host
* element. Inspected against the rendered light DOM, since projected nodes remain DOM
* descendants of the host element after `<ng-content/>` rendering.
*/
private _hasProjectedLabelContent(): boolean {
const host = this._elementRef.nativeElement;
if (!host) {
return false;
}
// Element nodes inside the rendered `<label/>` indicate projected element content.
const label = host.querySelector('.mdc-label');
if (label) {
for (let i = 0; i < label.childNodes.length; i++) {
const node = label.childNodes[i];
if (node.nodeType === Node.ELEMENT_NODE) {
return true;
}
if (node.nodeType === Node.TEXT_NODE && (node.textContent?.trim().length ?? 0) > 0) {
return true;
}
}
return false;
}
// Fallback: trimmed host text content reflects any projected text. Used when the `<label/>`
// has not yet been rendered (e.g. on the first content-init pass).
return (host.textContent?.trim().length ?? 0) > 0;
}

/** Whether the checkbox is checked. */
@Input({transform: booleanAttribute})
get checked(): boolean {
Expand Down Expand Up @@ -535,7 +586,8 @@ export class MatCheckbox
* bubbles when the label is clicked.
*/
_preventBubblingFromLabel(event: MouseEvent) {
if (!!event.target && this._labelElement.nativeElement.contains(event.target as HTMLElement)) {
const labelElement = this._labelElement?.nativeElement;
if (!!event.target && labelElement?.contains(event.target as HTMLElement)) {
event.stopPropagation();
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/material/checkbox/testing/checkbox-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class MatCheckboxHarness extends ComponentHarness {
static hostSelector = '.mat-mdc-checkbox';

_input = this.locatorFor('input');
private _label = this.locatorFor('label');
private _label = this.locatorForOptional('label');
private _inputContainer = this.locatorFor('.mdc-checkbox');

/**
Expand Down Expand Up @@ -116,7 +116,8 @@ export class MatCheckboxHarness extends ComponentHarness {

/** Gets the checkbox's label text. */
async getLabelText(): Promise<string> {
return (await this._label()).text();
const label = await this._label();
return label ? label.text() : '';
}

/** Focuses the checkbox. */
Expand Down
Loading