Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(focustrap): run focus trap event handlers outside Angular #3435

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/datepicker/datepicker-input.ts
Expand Up @@ -348,7 +348,7 @@ export class NgbInputDatepicker implements OnChanges,

// focus handling
this._elWithFocus = this._document.activeElement;
ngbFocusTrap(this._cRef.location.nativeElement, this.closed, true);
ngbFocusTrap(this._ngZone, this._cRef.location.nativeElement, this.closed, true);
this._cRef.instance.focus();

ngbAutoClose(
Expand Down
7 changes: 4 additions & 3 deletions src/modal/modal-stack.ts
Expand Up @@ -6,8 +6,9 @@ import {
Inject,
Injectable,
Injector,
NgZone,
RendererFactory2,
TemplateRef,
TemplateRef
} from '@angular/core';
import {Subject} from 'rxjs';

Expand All @@ -32,12 +33,12 @@ export class NgbModalStack {

constructor(
private _applicationRef: ApplicationRef, private _injector: Injector, @Inject(DOCUMENT) private _document: any,
private _scrollBar: ScrollBar, private _rendererFactory: RendererFactory2) {
private _scrollBar: ScrollBar, private _rendererFactory: RendererFactory2, private _ngZone: NgZone) {
// Trap focus on active WindowCmpt
this._activeWindowCmptHasChanged.subscribe(() => {
if (this._windowCmpts.length) {
const activeWindowCmpt = this._windowCmpts[this._windowCmpts.length - 1];
ngbFocusTrap(activeWindowCmpt.location.nativeElement, this._activeWindowCmptHasChanged);
ngbFocusTrap(this._ngZone, activeWindowCmpt.location.nativeElement, this._activeWindowCmptHasChanged);
this._revertAriaHidden();
this._setAriaHidden(activeWindowCmpt.location.nativeElement);
}
Expand Down
66 changes: 36 additions & 30 deletions src/util/focus-trap.ts
@@ -1,7 +1,9 @@
import {NgZone} from '@angular/core';

import {fromEvent, Observable} from 'rxjs';
import {filter, map, takeUntil, withLatestFrom} from 'rxjs/operators';

import {Key} from '../util/key';
import {Key} from './key';


const FOCUSABLE_ELEMENTS_SELECTOR = [
Expand All @@ -24,43 +26,47 @@ export function getFocusableBoundaryElements(element: HTMLElement): HTMLElement[
*
* Works only for clicks inside the element and navigation with 'Tab', ignoring clicks outside of the element
*
* @param zone Angular zone
* @param element The element around which focus will be trapped inside
* @param stopFocusTrap$ The observable stream. When completed the focus trap will clean up listeners
* and free internal resources
* @param refocusOnClick Put the focus back to the last focused element whenever a click occurs on element (default to
* false)
*/
export const ngbFocusTrap = (element: HTMLElement, stopFocusTrap$: Observable<any>, refocusOnClick = false) => {
// last focused element
const lastFocusedElement$ =
fromEvent<FocusEvent>(element, 'focusin').pipe(takeUntil(stopFocusTrap$), map(e => e.target));
export const ngbFocusTrap =
(zone: NgZone, element: HTMLElement, stopFocusTrap$: Observable<any>, refocusOnClick = false) => {
zone.runOutsideAngular(() => {
// last focused element
const lastFocusedElement$ =
fromEvent<FocusEvent>(element, 'focusin').pipe(takeUntil(stopFocusTrap$), map(e => e.target));

// 'tab' / 'shift+tab' stream
fromEvent<KeyboardEvent>(element, 'keydown')
.pipe(
takeUntil(stopFocusTrap$),
// tslint:disable:deprecation
filter(e => e.which === Key.Tab),
// tslint:enable:deprecation
withLatestFrom(lastFocusedElement$))
.subscribe(([tabEvent, focusedElement]) => {
const[first, last] = getFocusableBoundaryElements(element);
// 'tab' / 'shift+tab' stream
fromEvent<KeyboardEvent>(element, 'keydown')
.pipe(
takeUntil(stopFocusTrap$),
// tslint:disable:deprecation
filter(e => e.which === Key.Tab),
// tslint:enable:deprecation
withLatestFrom(lastFocusedElement$))
.subscribe(([tabEvent, focusedElement]) => {
const[first, last] = getFocusableBoundaryElements(element);

if ((focusedElement === first || focusedElement === element) && tabEvent.shiftKey) {
last.focus();
tabEvent.preventDefault();
}
if ((focusedElement === first || focusedElement === element) && tabEvent.shiftKey) {
last.focus();
tabEvent.preventDefault();
}

if (focusedElement === last && !tabEvent.shiftKey) {
first.focus();
tabEvent.preventDefault();
if (focusedElement === last && !tabEvent.shiftKey) {
first.focus();
tabEvent.preventDefault();
}
});

// inside click
if (refocusOnClick) {
fromEvent(element, 'click')
.pipe(takeUntil(stopFocusTrap$), withLatestFrom(lastFocusedElement$), map(arr => arr[1] as HTMLElement))
.subscribe(lastFocusedElement => lastFocusedElement.focus());
}
});

// inside click
if (refocusOnClick) {
fromEvent(element, 'click')
.pipe(takeUntil(stopFocusTrap$), withLatestFrom(lastFocusedElement$), map(arr => arr[1] as HTMLElement))
.subscribe(lastFocusedElement => lastFocusedElement.focus());
}
};
};