Skip to content

Commit 089eb6f

Browse files
authored
fix(cdk/a11y): correctly detect focus from input label (#25232)
In most cases focus moves during the `mousedown` event so all of our detection uses `mousedown` events to track it. It breaks down for the common use case where a `label` is connected to an `input`, because there focus moves on the `click` event instead. This has been a long-standing issue with the `FocusMonitor` that has caused problems with `mat-checkbox`, `mat-radio-button` and `mat-slide-toggle`. These changes add special handling for the `input` + `label` case that checks if the previous mouse interaction was with a label belonging to the current `input` receiving focus. Fixes #25090.
1 parent 0fa6fc1 commit 089eb6f

File tree

2 files changed

+105
-3
lines changed

2 files changed

+105
-3
lines changed

src/cdk/a11y/focus-monitor/focus-monitor.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,51 @@ describe('FocusMonitor observable stream', () => {
778778
}));
779779
});
780780

781+
describe('FocusMonitor input label detection', () => {
782+
let fixture: ComponentFixture<CheckboxWithLabel>;
783+
let inputElement: HTMLElement;
784+
let labelElement: HTMLElement;
785+
let focusMonitor: FocusMonitor;
786+
787+
beforeEach(() => {
788+
TestBed.configureTestingModule({
789+
imports: [A11yModule],
790+
declarations: [CheckboxWithLabel],
791+
}).compileComponents();
792+
});
793+
794+
beforeEach(inject([FocusMonitor], (fm: FocusMonitor) => {
795+
fixture = TestBed.createComponent(CheckboxWithLabel);
796+
focusMonitor = fm;
797+
fixture.detectChanges();
798+
inputElement = fixture.nativeElement.querySelector('input');
799+
labelElement = fixture.nativeElement.querySelector('label');
800+
patchElementFocus(inputElement);
801+
}));
802+
803+
it('should detect label click focus as `mouse`', fakeAsync(() => {
804+
const spy = jasmine.createSpy('monitor spy');
805+
focusMonitor.monitor(inputElement).subscribe(spy);
806+
expect(spy).not.toHaveBeenCalled();
807+
808+
// Unlike most focus, focus from labels moves to the connected input on click rather than
809+
// `mousedown`. To simulate it we have to dispatch both `mousedown` and `click` so the
810+
// modality detector will pick it up.
811+
dispatchMouseEvent(labelElement, 'mousedown');
812+
labelElement.click();
813+
fixture.detectChanges();
814+
flush();
815+
816+
// The programmatic click from above won't move focus so we have to focus the input ourselves.
817+
inputElement.focus();
818+
fixture.detectChanges();
819+
tick();
820+
821+
expect(inputElement.classList).toContain('cdk-mouse-focused');
822+
expect(spy.calls.mostRecent()?.args[0]).toBe('mouse');
823+
}));
824+
});
825+
781826
@Component({
782827
template: `<div class="parent"><button>focus me!</button></div>`,
783828
})
@@ -809,3 +854,11 @@ class ComplexComponentWithMonitorSubtreeFocusAndMonitorElementFocus {}
809854
template: `<ng-container cdkMonitorElementFocus></ng-container>`,
810855
})
811856
class FocusMonitorOnCommentNode {}
857+
858+
@Component({
859+
template: `
860+
<label for="test-checkbox">Check me</label>
861+
<input id="test-checkbox" type="checkbox">
862+
`,
863+
})
864+
class CheckboxWithLabel {}

src/cdk/a11y/focus-monitor/focus-monitor.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,14 @@ export class FocusMonitor implements OnDestroy {
160160
*/
161161
private _rootNodeFocusAndBlurListener = (event: Event) => {
162162
const target = _getEventTarget<HTMLElement>(event);
163-
const handler = event.type === 'focus' ? this._onFocus : this._onBlur;
164163

165164
// We need to walk up the ancestor chain in order to support `checkChildren`.
166165
for (let element = target; element; element = element.parentElement) {
167-
handler.call(this, event as FocusEvent, element);
166+
if (event.type === 'focus') {
167+
this._onFocus(event as FocusEvent, element);
168+
} else {
169+
this._onBlur(event as FocusEvent, element);
170+
}
168171
}
169172
};
170173

@@ -328,7 +331,19 @@ export class FocusMonitor implements OnDestroy {
328331
// events).
329332
//
330333
// Because we can't distinguish between these two cases, we default to setting `program`.
331-
return this._windowFocused && this._lastFocusOrigin ? this._lastFocusOrigin : 'program';
334+
if (this._windowFocused && this._lastFocusOrigin) {
335+
return this._lastFocusOrigin;
336+
}
337+
338+
// If the interaction is coming from an input label, we consider it a mouse interactions.
339+
// This is a special case where focus moves on `click`, rather than `mousedown` which breaks
340+
// our detection, because all our assumptions are for `mousedown`. We need to handle this
341+
// special case, because it's very common for checkboxes and radio buttons.
342+
if (focusEventTarget && this._isLastInteractionFromInputLabel(focusEventTarget)) {
343+
return 'mouse';
344+
}
345+
346+
return 'program';
332347
}
333348

334349
/**
@@ -552,6 +567,40 @@ export class FocusMonitor implements OnDestroy {
552567

553568
return results;
554569
}
570+
571+
/**
572+
* Returns whether an interaction is likely to have come from the user clicking the `label` of
573+
* an `input` or `textarea` in order to focus it.
574+
* @param focusEventTarget Target currently receiving focus.
575+
*/
576+
private _isLastInteractionFromInputLabel(focusEventTarget: HTMLElement): boolean {
577+
const {_mostRecentTarget: mostRecentTarget, mostRecentModality} = this._inputModalityDetector;
578+
579+
// If the last interaction used the mouse on an element contained by one of the labels
580+
// of an `input`/`textarea` that is currently focused, it is very likely that the
581+
// user redirected focus using the label.
582+
if (
583+
mostRecentModality !== 'mouse' ||
584+
!mostRecentTarget ||
585+
mostRecentTarget === focusEventTarget ||
586+
(focusEventTarget.nodeName !== 'INPUT' && focusEventTarget.nodeName !== 'TEXTAREA') ||
587+
(focusEventTarget as HTMLInputElement | HTMLTextAreaElement).disabled
588+
) {
589+
return false;
590+
}
591+
592+
const labels = (focusEventTarget as HTMLInputElement | HTMLTextAreaElement).labels;
593+
594+
if (labels) {
595+
for (let i = 0; i < labels.length; i++) {
596+
if (labels[i].contains(mostRecentTarget)) {
597+
return true;
598+
}
599+
}
600+
}
601+
602+
return false;
603+
}
555604
}
556605

557606
/**

0 commit comments

Comments
 (0)