mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-19 02:51:14 +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:
parent
815b436f7c
commit
24fe836032
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
if (this.ngZone.isStable) {
|
||||||
this.el.nativeElement.focus();
|
this.el.nativeElement.focus();
|
||||||
|
} else {
|
||||||
|
this.ngZone.onStable.pipe(take(1)).subscribe(() => this.el.nativeElement.focus());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user