diff --git a/src/components/modal/index.ts b/src/components/modal/index.ts index 50593f13f..8d48ec982 100644 --- a/src/components/modal/index.ts +++ b/src/components/modal/index.ts @@ -29,6 +29,8 @@ class Modal implements ModalInterface { _keydownEventListener: EventListenerOrEventListenerObject; _eventListenerInstances: EventListenerInstance[] = []; _initialized: boolean; + _lastActiveElement: HTMLElement | null; + _focusTrapEventListener: EventListenerOrEventListenerObject; constructor( targetEl: HTMLElement | null = null, @@ -43,6 +45,7 @@ class Modal implements ModalInterface { this._isHidden = true; this._backdropEl = null; this._initialized = false; + this._lastActiveElement = null; this.init(); instances.addInstance( 'Modal', @@ -63,6 +66,7 @@ class Modal implements ModalInterface { destroy() { if (this._initialized) { + this._removeFocusTrap(); this.removeAllEventListenerInstances(); this._destroyBackdropEl(); this._initialized = false; @@ -169,12 +173,79 @@ class Modal implements ModalInterface { return ['justify-center', 'items-end']; case 'bottom-right': return ['justify-end', 'items-end']; - default: return ['justify-center', 'items-center']; } } + _getFocusableElements(): HTMLElement[] { + if (!this._targetEl) return []; + + const selector = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.from( + this._targetEl.querySelectorAll(selector) + ) as HTMLElement[]; + } + + _setupFocusTrap(): void { + if (!this._targetEl) return; + + this._lastActiveElement = document.activeElement as HTMLElement; + + const focusableSelector = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + const focusableElements = Array.from( + this._targetEl.querySelectorAll(focusableSelector) + ) as HTMLElement[]; + + // If no focusable elements, focus on modal + if (focusableElements.length === 0) { + this._targetEl.setAttribute('tabindex', '-1'); + this._targetEl.focus(); + return; + } + + setTimeout(() => { + // Focus on 1st focusable element, usually the close button + focusableElements[0].focus(); + }, 50); + + this._focusTrapEventListener = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + // Trap focus within the modal + if (event.shiftKey && document.activeElement === firstElement) { + lastElement.focus(); + event.preventDefault(); + } else if ( + !event.shiftKey && + document.activeElement === lastElement + ) { + firstElement.focus(); + event.preventDefault(); + } + }; + + document.addEventListener('keydown', this._focusTrapEventListener); + } + + _removeFocusTrap(): void { + if (this._focusTrapEventListener) { + document.removeEventListener( + 'keydown', + this._focusTrapEventListener + ); + } + + if (this._lastActiveElement) { + setTimeout(() => this._lastActiveElement?.focus(), 50); + } + } + toggle() { if (this._isHidden) { this.show(); @@ -201,6 +272,8 @@ class Modal implements ModalInterface { this._setupModalCloseEventListeners(); } + this._setupFocusTrap(); + // prevent body scroll document.body.classList.add('overflow-hidden'); @@ -211,6 +284,7 @@ class Modal implements ModalInterface { hide() { if (this.isVisible) { + this._removeFocusTrap(); this._targetEl.classList.add('hidden'); this._targetEl.classList.remove('flex'); this._targetEl.setAttribute('aria-hidden', 'true'); diff --git a/src/components/modal/interface.ts b/src/components/modal/interface.ts index 92da32b0c..622da4f22 100644 --- a/src/components/modal/interface.ts +++ b/src/components/modal/interface.ts @@ -17,6 +17,10 @@ export declare interface ModalInterface { _keydownEventListener: EventListenerOrEventListenerObject; + // Focus trap related properties + _lastActiveElement: HTMLElement | null; + _focusTrapEventListener: EventListenerOrEventListenerObject; + // Initializes the modal and sets up its event listeners init(): void; @@ -29,6 +33,15 @@ export declare interface ModalInterface { // Sets up event listeners for the modal to allow it to be closed when clicked outside or the Escape key is pressed _setupModalCloseEventListeners(): void; + // Sets up focus trapping within the modal + _setupFocusTrap(): void; + + // Removes focus trap event listeners + _removeFocusTrap(): void; + + // Gets all focusable elements within the modal + _getFocusableElements(): HTMLElement[]; + // Handles clicks outside the modal and hides it if necessary _handleOutsideClick(target: EventTarget): void;