import { IComponentController } from 'angular' import _ from 'lodash' import { Component, Inject, Input, Output } from '../decorators' import MflyModalService from './modal.service' export interface ModalDismissConfirmation { cancelButtonText: string confirmButtonText: string message: string title: string } @Component({ selector: 'mflyModal', template: require('./modal.component.html'), transclude: { header: '?mflyModalHeader', body: 'mflyModalBody', footer: '?mflyModalFooter', }, }) export default class MflyModalComponentController implements IComponentController { @Input('@') modalBodyClass?: string | undefined @Input('@') modalHeaderClass?: string | undefined @Input('@') modalSize?: 'large' | 'full' | undefined @Input('@') modalId: string | undefined @Input() modalAsyncContentLoaded?: boolean | undefined @Output() modalCloseCallback: () => void /** A callback that provides an optional "are you sure?" dialog when the modal is dismissed (canceled). Return `undefined` if no confirmation is required. */ @Output('&?') modalConfirmDismissCallback?: () => ModalDismissConfirmation | undefined protected dismissConfirmation: ModalDismissConfirmation | undefined protected hasFooterTransclusion: boolean protected hasHeaderTransclusion: boolean private focusableElementsInModal: JQuery private firstFocusable: HTMLElement private keyDownHandler: (event: KeyboardEvent) => void private lastFocusable: HTMLElement private modalElement: HTMLElement private hasAppliedFirstFocus: boolean = false constructor( @Inject('$attrs') private $attrs: ng.IAttributes, @Inject('$element') private $element: ng.IAugmentedJQuery, @Inject('$timeout') private $timeout: ng.ITimeoutService, @Inject('$transclude') private $transclude: ng.ITranscludeFunction, @Inject('$window') private $window: ng.IWindowService, @Inject('mflyModalService') private mflyModalService: MflyModalService, ) { } $onDestroy() { this.$window.removeEventListener('keydown', this.keyDownHandler) this.mflyModalService.cleanupDOM() } $onInit() { this.hasHeaderTransclusion = this.$transclude.isSlotFilled('header') this.hasFooterTransclusion = this.$transclude.isSlotFilled('footer') this.keyDownHandler = (event) => { this.handleKeydown(event) } this.$window.addEventListener('keydown', this.keyDownHandler) // Set up an observer for disabledness changes so that our focus trap will update itself. const observer = new MutationObserver(mutations => { mutations.forEach(_.debounce(() => this.calculateFocusableElements(true), 50)) }) observer.observe(this.$element[0], { attributes: true, attributeFilter: ['disabled'], childList: true, subtree: true, }) // We need this to render on load so focus will pick it up, but cannot render on show // because a screen reader would pick that up when it should not be there. if (this.modalSize !== 'full') { this.$element.find('button.c-modal__close').remove() } } $postLink() { this.$timeout(() => { if (!!this.modalHeaderClass) { this.$element.find('.c-modal__header').addClass(this.modalHeaderClass) } if (!!this.modalBodyClass) { this.$element.find('.c-modal__body').addClass(this.modalBodyClass) } this.calculateFocusableElements() }) // Timeout 0 only intermittently fires the animation; 50ms seems to be reliable. this.$timeout(() => { this.$element.find('.c-modal__backdrop').addClass('is-visible') if (this.modalAsyncContentLoaded === false) { this.modalElement?.focus() } if (this.modalAsyncContentLoaded === undefined) { this.firstFocusable.focus() this.hasAppliedFirstFocus = true } }, 50) } protected cancelDismiss(): void { this.dismissConfirmation = undefined } protected confirmDismiss(): void { if (this.$attrs.modalCloseCallback) { this.modalCloseCallback() } this.mflyModalService.dismiss() } protected dismiss(): void { this.$timeout(() => { this.dismissConfirmation = this.modalConfirmDismissCallback?.() if (this.dismissConfirmation) { return } this.confirmDismiss() }) } /** While the modal's open, trap focus and handle ESC to close. */ private handleKeydown(event: KeyboardEvent): void { if (event.key === 'Escape') { this.dismiss() } if (event.metaKey || event.ctrlKey || event.altKey) { return } if (event.key === 'Tab' && !event.shiftKey && event.target === this.modalElement) { this.firstFocusable.focus() event.preventDefault() } if (event.key === 'Tab' && event.shiftKey && event.target === this.modalElement) { this.lastFocusable.focus() event.preventDefault() } if (event.key === 'Tab' && event.shiftKey && event.target === this.firstFocusable) { this.lastFocusable.focus() event.preventDefault() } if (event.key === 'Tab' && !event.shiftKey && event.target === this.lastFocusable) { this.firstFocusable.focus() event.preventDefault() } } private calculateFocusableElements(checkAsyncLoadFocus: boolean = false) { const selectors = [ '[href]', '[tabindex]:not([tabindex="-1"])', 'a', 'button:not(:disabled)', 'input', 'li', 'select', 'textarea', ] if (!this.$element) { return } this.modalElement = this.$element.find('.c-modal')[0] this.focusableElementsInModal = this.$element.find(selectors.join(',')) this.firstFocusable = this.focusableElementsInModal[0] this.lastFocusable = this.focusableElementsInModal[this.focusableElementsInModal.length - 1] if (checkAsyncLoadFocus && this.modalAsyncContentLoaded && !this.hasAppliedFirstFocus) { this.firstFocusable.focus() this.hasAppliedFirstFocus = true } } }