1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-14 02:08:50 +02:00

Fixes for dynamic modal a11y (#518)

* Do not close modal if click finishes on background

* Trap tab focus in modals, use ESC to close modal

* Fix Angular change detection errors in modals

* Reset focus on next modal after closing modal

* Minor fixes and linting

* Attach focusTrap to modal-dialog element

* Change mousedown event back to click

* Make topModal private

* Add new div for dismissing modal by clicking bg

* Focus element in modal if no autoFocus directive

* Use backdrop for dismissal

* Fix typo
This commit is contained in:
Thomas Rittson 2021-10-21 08:13:37 +10:00 committed by GitHub
parent 815b436f7c
commit 24fe836032
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 58 additions and 18 deletions

View File

@ -10,6 +10,11 @@ import {
ViewContainerRef
} from '@angular/core';
import {
ConfigurableFocusTrap,
ConfigurableFocusTrapFactory,
} from '@angular/cdk/a11y';
import { ModalService } from '../../services/modal.service';
import { ModalRef } from './modal.ref';
@ -26,8 +31,11 @@ export class DynamicModalComponent implements AfterViewInit, OnDestroy {
childComponentType: Type<any>;
setComponentParameters: (component: any) => void;
private focusTrap: ConfigurableFocusTrap;
constructor(private modalService: ModalService, private cd: ChangeDetectorRef,
private el: ElementRef<HTMLElement>, public modalRef: ModalRef) {}
private el: ElementRef<HTMLElement>, private focusTrapFactory: ConfigurableFocusTrapFactory,
public modalRef: ModalRef) { }
ngAfterViewInit() {
this.loadChildComponent(this.childComponentType);
@ -37,6 +45,10 @@ export class DynamicModalComponent implements AfterViewInit, OnDestroy {
this.cd.detectChanges();
this.modalRef.created(this.el.nativeElement);
this.focusTrap = this.focusTrapFactory.create(this.el.nativeElement.querySelector('.modal-dialog'));
if (this.el.nativeElement.querySelector('[appAutoFocus]') == null) {
this.focusTrap.focusFirstTabbableElementWhenReady();
}
}
loadChildComponent(componentType: Type<any>) {
@ -50,9 +62,15 @@ export class DynamicModalComponent implements AfterViewInit, OnDestroy {
if (this.componentRef) {
this.componentRef.destroy();
}
this.focusTrap.destroy();
}
close() {
this.modalRef.close();
}
getFocus() {
const autoFocusEl = this.el.nativeElement.querySelector('[appAutoFocus]') as HTMLElement;
autoFocusEl?.focus();
}
}

View File

@ -2,8 +2,11 @@ import {
Directive,
ElementRef,
Input,
NgZone,
} from '@angular/core';
import { take } from 'rxjs/operators';
import { Utils } from 'jslib-common/misc/utils';
@Directive({
@ -16,11 +19,15 @@ export class AutofocusDirective {
private autofocus: boolean;
constructor(private el: ElementRef) { }
constructor(private el: ElementRef, private ngZone: NgZone) { }
ngOnInit() {
if (!Utils.isMobileBrowser && this.autofocus) {
this.el.nativeElement.focus();
if (this.ngZone.isStable) {
this.el.nativeElement.focus();
} else {
this.ngZone.onStable.pipe(take(1)).subscribe(() => this.el.nativeElement.focus());
}
}
}
}

View File

@ -22,19 +22,32 @@ export class ModalConfig<D = any> {
@Injectable()
export class ModalService {
protected modalCount = 0;
protected modalList: ComponentRef<DynamicModalComponent>[] = [];
// Lazy loaded modules are not available in componentFactoryResolver,
// therefore modules needs to manually initialize their resolvers.
private factoryResolvers: Map<Type<any>, ComponentFactoryResolver> = new Map();
constructor(private componentFactoryResolver: ComponentFactoryResolver, private applicationRef: ApplicationRef,
private injector: Injector) {}
private injector: Injector) {
document.addEventListener('keyup', event => {
if (event.key === 'Escape' && this.modalCount > 0) {
this.topModal.instance.close();
}
});
}
get modalCount() {
return this.modalList.length;
}
private get topModal() {
return this.modalList[this.modalCount - 1];
}
async openViewRef<T>(componentType: Type<T>, viewContainerRef: ViewContainerRef,
setComponentParameters: (component: T) => void = null): Promise<[ModalRef, T]> {
this.modalCount++;
const [modalRef, modalComponentRef] = this.openInternal(componentType, null, false);
modalComponentRef.instance.setComponentParameters = setComponentParameters;
@ -49,7 +62,6 @@ export class ModalService {
if (!(config?.allowMultipleModals ?? false) && this.modalCount > 0) {
return;
}
this.modalCount++;
const [modalRef, _] = this.openInternal(componentType, config, true);
@ -85,11 +97,17 @@ export class ModalService {
this.applicationRef.detachView(componentRef.hostView);
}
componentRef.destroy();
this.modalCount--;
this.modalList.pop();
if (this.modalCount > 0) {
this.topModal.instance.getFocus();
}
});
this.setupHandlers(modalRef);
this.modalList.push(componentRef);
return [modalRef, componentRef];
}
@ -100,19 +118,20 @@ export class ModalService {
modalRef.onCreated.pipe(first()).subscribe(el => {
document.body.classList.add('modal-open');
const modalEl: HTMLElement = el.querySelector('.modal');
const dialogEl = modalEl.querySelector('.modal-dialog') as HTMLElement;
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade';
backdrop.style.zIndex = `${this.modalCount}040`;
document.body.appendChild(backdrop);
modalEl.prepend(backdrop);
el.querySelector('.modal-dialog').addEventListener('click', (e: Event) => {
dialogEl.addEventListener('click', (e: Event) => {
e.stopPropagation();
});
dialogEl.style.zIndex = `${this.modalCount}050`;
const modalEl: HTMLElement = el.querySelector('.modal');
modalEl.style.zIndex = `${this.modalCount}050`;
const modals = Array.from(el.querySelectorAll('.modal, .modal *[data-dismiss="modal"]'));
const modals = Array.from(el.querySelectorAll('.modal-backdrop, .modal *[data-dismiss="modal"]'));
for (const closeElement of modals) {
closeElement.addEventListener('click', event => {
modalRef.close();
@ -127,10 +146,6 @@ export class ModalService {
if (this.modalCount === 0) {
document.body.classList.remove('modal-open');
}
if (backdrop != null) {
document.body.removeChild(backdrop);
}
});
}