From 853c77c96998f87b6663e3195aafa46cc9b2c7ae Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 6 Jul 2022 09:19:51 +0200 Subject: [PATCH] fix(cdk/a11y): correctly detect focus from input label 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. --- .../a11y/focus-monitor/focus-monitor.spec.ts | 53 ++++++++++++++++++ src/cdk/a11y/focus-monitor/focus-monitor.ts | 55 ++++++++++++++++++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts index 73e9ed4150f5..9d80e5fb65b5 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.spec.ts @@ -778,6 +778,51 @@ describe('FocusMonitor observable stream', () => { })); }); +describe('FocusMonitor input label detection', () => { + let fixture: ComponentFixture; + let inputElement: HTMLElement; + let labelElement: HTMLElement; + let focusMonitor: FocusMonitor; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [A11yModule], + declarations: [CheckboxWithLabel], + }).compileComponents(); + }); + + beforeEach(inject([FocusMonitor], (fm: FocusMonitor) => { + fixture = TestBed.createComponent(CheckboxWithLabel); + focusMonitor = fm; + fixture.detectChanges(); + inputElement = fixture.nativeElement.querySelector('input'); + labelElement = fixture.nativeElement.querySelector('label'); + patchElementFocus(inputElement); + })); + + it('should detect label click focus as `mouse`', fakeAsync(() => { + const spy = jasmine.createSpy('monitor spy'); + focusMonitor.monitor(inputElement).subscribe(spy); + expect(spy).not.toHaveBeenCalled(); + + // Unlike most focus, focus from labels moves to the connected input on click rather than + // `mousedown`. To simulate it we have to dispatch both `mousedown` and `click` so the + // modality detector will pick it up. + dispatchMouseEvent(labelElement, 'mousedown'); + labelElement.click(); + fixture.detectChanges(); + flush(); + + // The programmatic click from above won't move focus so we have to focus the input ourselves. + inputElement.focus(); + fixture.detectChanges(); + tick(); + + expect(inputElement.classList).toContain('cdk-mouse-focused'); + expect(spy.calls.mostRecent()?.args[0]).toBe('mouse'); + })); +}); + @Component({ template: `
`, }) @@ -809,3 +854,11 @@ class ComplexComponentWithMonitorSubtreeFocusAndMonitorElementFocus {} template: ``, }) class FocusMonitorOnCommentNode {} + +@Component({ + template: ` + + + `, +}) +class CheckboxWithLabel {} diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 692da9322e07..751b7f651001 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -160,11 +160,14 @@ export class FocusMonitor implements OnDestroy { */ private _rootNodeFocusAndBlurListener = (event: Event) => { const target = _getEventTarget(event); - const handler = event.type === 'focus' ? this._onFocus : this._onBlur; // We need to walk up the ancestor chain in order to support `checkChildren`. for (let element = target; element; element = element.parentElement) { - handler.call(this, event as FocusEvent, element); + if (event.type === 'focus') { + this._onFocus(event as FocusEvent, element); + } else { + this._onBlur(event as FocusEvent, element); + } } }; @@ -328,7 +331,19 @@ export class FocusMonitor implements OnDestroy { // events). // // Because we can't distinguish between these two cases, we default to setting `program`. - return this._windowFocused && this._lastFocusOrigin ? this._lastFocusOrigin : 'program'; + if (this._windowFocused && this._lastFocusOrigin) { + return this._lastFocusOrigin; + } + + // If the interaction is coming from an input label, we consider it a mouse interactions. + // This is a special case where focus moves on `click`, rather than `mousedown` which breaks + // our detection, because all our assumptions are for `mousedown`. We need to handle this + // special case, because it's very common for checkboxes and radio buttons. + if (focusEventTarget && this._isLastInteractionFromInputLabel(focusEventTarget)) { + return 'mouse'; + } + + return 'program'; } /** @@ -552,6 +567,40 @@ export class FocusMonitor implements OnDestroy { return results; } + + /** + * Returns whether an interaction is likely to have come from the user clicking the `label` of + * an `input` or `textarea` in order to focus it. + * @param focusEventTarget Target currently receiving focus. + */ + private _isLastInteractionFromInputLabel(focusEventTarget: HTMLElement): boolean { + const {_mostRecentTarget: mostRecentTarget, mostRecentModality} = this._inputModalityDetector; + + // If the last interaction used the mouse on an element contained by one of the labels + // of an `input`/`textarea` that is currently focused, it is very likely that the + // user redirected focus using the label. + if ( + mostRecentModality !== 'mouse' || + !mostRecentTarget || + mostRecentTarget === focusEventTarget || + (focusEventTarget.nodeName !== 'INPUT' && focusEventTarget.nodeName !== 'TEXTAREA') || + (focusEventTarget as HTMLInputElement | HTMLTextAreaElement).disabled + ) { + return false; + } + + const labels = (focusEventTarget as HTMLInputElement | HTMLTextAreaElement).labels; + + if (labels) { + for (let i = 0; i < labels.length; i++) { + if (labels[i].contains(mostRecentTarget)) { + return true; + } + } + } + + return false; + } } /**