Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion src/components/modal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class Modal implements ModalInterface {
_keydownEventListener: EventListenerOrEventListenerObject;
_eventListenerInstances: EventListenerInstance[] = [];
_initialized: boolean;
_lastActiveElement: HTMLElement | null;
_focusTrapEventListener: EventListenerOrEventListenerObject;

constructor(
targetEl: HTMLElement | null = null,
Expand All @@ -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',
Expand All @@ -63,6 +66,7 @@ class Modal implements ModalInterface {

destroy() {
if (this._initialized) {
this._removeFocusTrap();
this.removeAllEventListenerInstances();
this._destroyBackdropEl();
this._initialized = false;
Expand Down Expand Up @@ -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();
Expand All @@ -201,6 +272,8 @@ class Modal implements ModalInterface {
this._setupModalCloseEventListeners();
}

this._setupFocusTrap();

// prevent body scroll
document.body.classList.add('overflow-hidden');

Expand All @@ -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');
Expand Down
13 changes: 13 additions & 0 deletions src/components/modal/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand Down