1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-18 02:41:15 +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 ViewContainerRef
} from '@angular/core'; } from '@angular/core';
import {
ConfigurableFocusTrap,
ConfigurableFocusTrapFactory,
} from '@angular/cdk/a11y';
import { ModalService } from '../../services/modal.service'; import { ModalService } from '../../services/modal.service';
import { ModalRef } from './modal.ref'; import { ModalRef } from './modal.ref';
@ -26,8 +31,11 @@ export class DynamicModalComponent implements AfterViewInit, OnDestroy {
childComponentType: Type<any>; childComponentType: Type<any>;
setComponentParameters: (component: any) => void; setComponentParameters: (component: any) => void;
private focusTrap: ConfigurableFocusTrap;
constructor(private modalService: ModalService, private cd: ChangeDetectorRef, 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() { ngAfterViewInit() {
this.loadChildComponent(this.childComponentType); this.loadChildComponent(this.childComponentType);
@ -37,6 +45,10 @@ export class DynamicModalComponent implements AfterViewInit, OnDestroy {
this.cd.detectChanges(); this.cd.detectChanges();
this.modalRef.created(this.el.nativeElement); 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>) { loadChildComponent(componentType: Type<any>) {
@ -50,9 +62,15 @@ export class DynamicModalComponent implements AfterViewInit, OnDestroy {
if (this.componentRef) { if (this.componentRef) {
this.componentRef.destroy(); this.componentRef.destroy();
} }
this.focusTrap.destroy();
} }
close() { close() {
this.modalRef.close(); this.modalRef.close();
} }
getFocus() {
const autoFocusEl = this.el.nativeElement.querySelector('[appAutoFocus]') as HTMLElement;
autoFocusEl?.focus();
}
} }

View File

@ -2,8 +2,11 @@ import {
Directive, Directive,
ElementRef, ElementRef,
Input, Input,
NgZone,
} from '@angular/core'; } from '@angular/core';
import { take } from 'rxjs/operators';
import { Utils } from 'jslib-common/misc/utils'; import { Utils } from 'jslib-common/misc/utils';
@Directive({ @Directive({
@ -16,11 +19,15 @@ export class AutofocusDirective {
private autofocus: boolean; private autofocus: boolean;
constructor(private el: ElementRef) { } constructor(private el: ElementRef, private ngZone: NgZone) { }
ngOnInit() { ngOnInit() {
if (!Utils.isMobileBrowser && this.autofocus) { 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() @Injectable()
export class ModalService { export class ModalService {
protected modalCount = 0; protected modalList: ComponentRef<DynamicModalComponent>[] = [];
// Lazy loaded modules are not available in componentFactoryResolver, // Lazy loaded modules are not available in componentFactoryResolver,
// therefore modules needs to manually initialize their resolvers. // therefore modules needs to manually initialize their resolvers.
private factoryResolvers: Map<Type<any>, ComponentFactoryResolver> = new Map(); private factoryResolvers: Map<Type<any>, ComponentFactoryResolver> = new Map();
constructor(private componentFactoryResolver: ComponentFactoryResolver, private applicationRef: ApplicationRef, 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, async openViewRef<T>(componentType: Type<T>, viewContainerRef: ViewContainerRef,
setComponentParameters: (component: T) => void = null): Promise<[ModalRef, T]> { setComponentParameters: (component: T) => void = null): Promise<[ModalRef, T]> {
this.modalCount++;
const [modalRef, modalComponentRef] = this.openInternal(componentType, null, false); const [modalRef, modalComponentRef] = this.openInternal(componentType, null, false);
modalComponentRef.instance.setComponentParameters = setComponentParameters; modalComponentRef.instance.setComponentParameters = setComponentParameters;
@ -49,7 +62,6 @@ export class ModalService {
if (!(config?.allowMultipleModals ?? false) && this.modalCount > 0) { if (!(config?.allowMultipleModals ?? false) && this.modalCount > 0) {
return; return;
} }
this.modalCount++;
const [modalRef, _] = this.openInternal(componentType, config, true); const [modalRef, _] = this.openInternal(componentType, config, true);
@ -85,11 +97,17 @@ export class ModalService {
this.applicationRef.detachView(componentRef.hostView); this.applicationRef.detachView(componentRef.hostView);
} }
componentRef.destroy(); componentRef.destroy();
this.modalCount--;
this.modalList.pop();
if (this.modalCount > 0) {
this.topModal.instance.getFocus();
}
}); });
this.setupHandlers(modalRef); this.setupHandlers(modalRef);
this.modalList.push(componentRef);
return [modalRef, componentRef]; return [modalRef, componentRef];
} }
@ -100,19 +118,20 @@ export class ModalService {
modalRef.onCreated.pipe(first()).subscribe(el => { modalRef.onCreated.pipe(first()).subscribe(el => {
document.body.classList.add('modal-open'); 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 = document.createElement('div');
backdrop.className = 'modal-backdrop fade'; backdrop.className = 'modal-backdrop fade';
backdrop.style.zIndex = `${this.modalCount}040`; 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(); e.stopPropagation();
}); });
dialogEl.style.zIndex = `${this.modalCount}050`;
const modalEl: HTMLElement = el.querySelector('.modal'); const modals = Array.from(el.querySelectorAll('.modal-backdrop, .modal *[data-dismiss="modal"]'));
modalEl.style.zIndex = `${this.modalCount}050`;
const modals = Array.from(el.querySelectorAll('.modal, .modal *[data-dismiss="modal"]'));
for (const closeElement of modals) { for (const closeElement of modals) {
closeElement.addEventListener('click', event => { closeElement.addEventListener('click', event => {
modalRef.close(); modalRef.close();
@ -127,10 +146,6 @@ export class ModalService {
if (this.modalCount === 0) { if (this.modalCount === 0) {
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
} }
if (backdrop != null) {
document.body.removeChild(backdrop);
}
}); });
} }