Skip to content

Commit c9a63a6

Browse files
committed
fix(cdk/a11y): focusVia not accounting for focused child node
In #20966 some logic was added so that calling `focusVia` on an element that already has focus would change the origin to the passed-in one. The problem is that the new logic doesn't account for when a parent element is monitored and `focusVia` is called on a child. These changes add some more logic that will look through all the monitored elements that have `checkChildren: true` and will switch the origin accordingly. Fixes #21500.
1 parent 27e60e8 commit c9a63a6

File tree

2 files changed

+63
-2
lines changed

2 files changed

+63
-2
lines changed

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,40 @@ describe('FocusMonitor', () => {
352352
expect(buttonElement.focus).toHaveBeenCalledTimes(1);
353353
}));
354354

355+
it('focusVia should change the focus origin when called a focused child node', fakeAsync(() => {
356+
const parent = fixture.nativeElement.querySelector('.parent');
357+
focusMonitor.stopMonitoring(buttonElement); // The button gets monitored by default.
358+
focusMonitor.monitor(parent, true).subscribe(changeHandler);
359+
spyOn(buttonElement, 'focus').and.callThrough();
360+
focusMonitor.focusVia(buttonElement, 'keyboard');
361+
flush();
362+
fakeActiveElement = buttonElement;
363+
364+
expect(parent.classList.length)
365+
.toBe(3, 'Parent should have exactly 2 focus classes and the `parent` class');
366+
expect(parent.classList.contains('cdk-focused'))
367+
.toBe(true, 'Parent should have cdk-focused class');
368+
expect(parent.classList.contains('cdk-keyboard-focused'))
369+
.toBe(true, 'Parent should have cdk-keyboard-focused class');
370+
expect(changeHandler).toHaveBeenCalledTimes(1);
371+
expect(changeHandler).toHaveBeenCalledWith('keyboard');
372+
expect(buttonElement.focus).toHaveBeenCalledTimes(1);
373+
374+
focusMonitor.focusVia(buttonElement, 'mouse');
375+
flush();
376+
fakeActiveElement = buttonElement;
377+
378+
expect(parent.classList.length)
379+
.toBe(3, 'Parent should have exactly 2 focus classes and the `parent` class');
380+
expect(parent.classList.contains('cdk-focused'))
381+
.toBe(true, 'Parent should have cdk-focused class');
382+
expect(parent.classList.contains('cdk-mouse-focused'))
383+
.toBe(true, 'Parent should have cdk-mouse-focused class');
384+
expect(changeHandler).toHaveBeenCalledTimes(2);
385+
expect(changeHandler).toHaveBeenCalledWith('mouse');
386+
expect(buttonElement.focus).toHaveBeenCalledTimes(1);
387+
}));
388+
355389
});
356390

357391
describe('FocusMonitor with "eventual" detection', () => {

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,12 @@ export class FocusMonitor implements OnDestroy {
313313
// If the element is focused already, calling `focus` again won't trigger the event listener
314314
// which means that the focus classes won't be updated. If that's the case, update the classes
315315
// directly without waiting for an event.
316-
if (nativeElement === focusedElement && this._elementInfo.has(nativeElement)) {
317-
this._originChanged(nativeElement, origin, this._elementInfo.get(nativeElement)!);
316+
if (nativeElement === focusedElement) {
317+
const info = this._getClosestElementInfo(nativeElement);
318+
319+
if (info) {
320+
this._originChanged(info[0], origin, info[1]);
321+
}
318322
} else {
319323
this._setOriginForCurrentEventQueue(origin);
320324

@@ -553,6 +557,29 @@ export class FocusMonitor implements OnDestroy {
553557
this._emitOrigin(elementInfo.subject, origin);
554558
this._lastFocusOrigin = origin;
555559
}
560+
561+
/**
562+
* Gets the `MonitoredElementInfo` of an element, or the closest
563+
* monitored parent that has `checkChildren` enabled.
564+
* @param element Element from which to start the search.
565+
*/
566+
private _getClosestElementInfo(element: HTMLElement): [HTMLElement, MonitoredElementInfo] | null {
567+
const ownInfo = this._elementInfo.get(element);
568+
569+
// Do a quick constant-time lookup for the element itself.
570+
if (ownInfo) {
571+
return [element, ownInfo];
572+
}
573+
574+
// Otherwise do a linear lookup only for the elements that are monitoring child nodes.
575+
for (const current of this._elementInfo) {
576+
if (current[1].checkChildren && current[0].contains(element)) {
577+
return current;
578+
}
579+
}
580+
581+
return null;
582+
}
556583
}
557584

558585
/** Gets the target of an event, accounting for Shadow DOM. */

0 commit comments

Comments
 (0)