@gleb.askerko/componentkit-js
Version:
Lightweight, framework-agnostic JavaScript component library with progress gift components
284 lines (231 loc) • 7.04 kB
JavaScript
export class Modal {
constructor(options = {}) {
this.options = {
title: '',
content: '',
size: 'medium',
closable: true,
closeOnBackdrop: true,
closeOnEscape: true,
actions: [],
className: '',
onOpen: null,
onClose: null,
...options
};
this.element = null;
this.overlay = null;
this.isOpen = false;
this.actionButtons = [];
this.previousFocus = null;
}
open() {
if (this.isOpen) return this;
this.previousFocus = document.activeElement;
this.createElement();
document.body.appendChild(this.overlay);
// Force reflow for smooth animation
this.overlay.offsetHeight;
this.overlay.classList.add('ck-modal--open');
this.isOpen = true;
this.bindEvents();
this.trapFocus();
if (this.options.onOpen) {
this.options.onOpen();
}
return this;
}
close() {
if (!this.isOpen) return this;
this.overlay.classList.remove('ck-modal--open');
// Wait for animation to complete
setTimeout(() => {
this.destroy();
this.isOpen = false;
// Restore focus
if (this.previousFocus) {
this.previousFocus.focus();
}
if (this.options.onClose) {
this.options.onClose();
}
}, 300);
return this;
}
createElement() {
// Create overlay
this.overlay = document.createElement('div');
this.overlay.className = 'ck-modal-overlay';
// Create modal
this.element = document.createElement('div');
this.element.className = this.getModalClasses();
this.element.setAttribute('role', 'dialog');
this.element.setAttribute('aria-modal', 'true');
if (this.options.title) {
this.element.setAttribute('aria-labelledby', 'ck-modal-title');
}
// Create header
if (this.options.title) {
const header = this.createHeader();
this.element.appendChild(header);
}
// Create body
if (this.options.content) {
const body = this.createBody();
this.element.appendChild(body);
}
// Create footer
if (this.options.actions && this.options.actions.length > 0) {
const footer = this.createFooter();
this.element.appendChild(footer);
}
this.overlay.appendChild(this.element);
}
createHeader() {
const header = document.createElement('div');
header.className = 'ck-modal-header';
const title = document.createElement('h2');
title.id = 'ck-modal-title';
title.className = 'ck-modal-title';
title.textContent = this.options.title;
header.appendChild(title);
if (this.options.closable) {
const closeButton = document.createElement('button');
closeButton.className = 'ck-modal-close';
closeButton.innerHTML = '×';
closeButton.setAttribute('aria-label', 'Close modal');
closeButton.addEventListener('click', () => this.close());
header.appendChild(closeButton);
}
return header;
}
createBody() {
const body = document.createElement('div');
body.className = 'ck-modal-body';
if (typeof this.options.content === 'string') {
const content = document.createElement('div');
content.className = 'ck-modal-content';
content.textContent = this.options.content;
body.appendChild(content);
} else if (this.options.content instanceof HTMLElement) {
body.appendChild(this.options.content);
}
return body;
}
createFooter() {
const footer = document.createElement('div');
footer.className = 'ck-modal-footer';
this.options.actions.forEach((action, index) => {
const button = this.createActionButton(action, index);
footer.appendChild(button);
this.actionButtons.push(button);
});
return footer;
}
createActionButton(action, index) {
const button = document.createElement('button');
button.className = `ck-button ${action.variant ? `ck-button--${action.variant}` : 'ck-button--primary'}`;
button.textContent = action.text || 'Action';
button.disabled = action.disabled || false;
button.addEventListener('click', (e) => {
if (action.onClick) {
const result = action.onClick(e, index);
// Auto-close modal unless action returns false
if (result !== false && action.autoClose !== false) {
this.close();
}
} else {
this.close();
}
});
return button;
}
getModalClasses() {
const baseClasses = 'ck-modal';
const sizeClasses = {
small: 'ck-modal--small',
medium: 'ck-modal--medium',
large: 'ck-modal--large'
};
return [
baseClasses,
sizeClasses[this.options.size] || sizeClasses.medium,
this.options.className
].filter(Boolean).join(' ');
}
bindEvents() {
// Close on backdrop click
if (this.options.closeOnBackdrop) {
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) {
this.close();
}
});
}
// Close on escape key
if (this.options.closeOnEscape) {
this.escapeHandler = (e) => {
if (e.key === 'Escape') {
this.close();
}
};
document.addEventListener('keydown', this.escapeHandler);
}
}
trapFocus() {
const focusableElements = this.element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
this.tabHandler = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', this.tabHandler);
firstElement.focus();
}
update(newOptions) {
this.options = { ...this.options, ...newOptions };
if (this.isOpen) {
// Re-render the modal
this.close();
setTimeout(() => this.open(), 300);
}
return this;
}
destroy() {
// Clean up event listeners
if (this.escapeHandler) {
document.removeEventListener('keydown', this.escapeHandler);
}
if (this.tabHandler) {
document.removeEventListener('keydown', this.tabHandler);
}
// Clean up action buttons
this.actionButtons.forEach(button => {
if (button.parentNode) {
button.parentNode.removeChild(button);
}
});
// Remove from DOM
if (this.overlay && this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
this.element = null;
this.overlay = null;
this.actionButtons = [];
}
}