mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
200 lines
5.5 KiB
JavaScript
200 lines
5.5 KiB
JavaScript
/**
|
|
* Composable Modal Component Template
|
|
*
|
|
* Based on James Simone's composable modal pattern with:
|
|
* - Focus trap implementation
|
|
* - ESC key to close
|
|
* - Backdrop click to close
|
|
* - Slot-based composition
|
|
* - ARIA accessibility attributes
|
|
* - Proper cleanup on disconnect
|
|
*
|
|
* @see https://www.jamessimone.net/blog/joys-of-apex/lwc-composable-modal/
|
|
*/
|
|
import { LightningElement, api } from 'lwc';
|
|
|
|
const OUTER_MODAL_CLASS = 'outerModalContent';
|
|
const ESCAPE_KEY = 'Escape';
|
|
const TAB_KEY = 'Tab';
|
|
|
|
export default class ModalComponent extends LightningElement {
|
|
// Public API properties
|
|
@api modalHeader;
|
|
@api modalTagline;
|
|
@api modalSaveHandler;
|
|
@api hideFooter = false;
|
|
@api size = 'medium'; // small, medium, large
|
|
|
|
// Private state
|
|
_isOpen = false;
|
|
_focusableElements = [];
|
|
_boundHandleKeyUp;
|
|
|
|
constructor() {
|
|
super();
|
|
// Bind the handler once to ensure we can remove it later
|
|
this._boundHandleKeyUp = this._handleKeyUp.bind(this);
|
|
}
|
|
|
|
/**
|
|
* Public method to toggle modal visibility
|
|
* Called by parent component to open/close the modal
|
|
*/
|
|
@api
|
|
toggleModal() {
|
|
this._isOpen = !this._isOpen;
|
|
|
|
if (this._isOpen) {
|
|
this._onOpen();
|
|
} else {
|
|
this._onClose();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Public method to explicitly open the modal
|
|
*/
|
|
@api
|
|
open() {
|
|
if (!this._isOpen) {
|
|
this._isOpen = true;
|
|
this._onOpen();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Public method to explicitly close the modal
|
|
*/
|
|
@api
|
|
close() {
|
|
if (this._isOpen) {
|
|
this._isOpen = false;
|
|
this._onClose();
|
|
}
|
|
}
|
|
|
|
// Computed properties for template
|
|
get isOpen() {
|
|
return this._isOpen;
|
|
}
|
|
|
|
get modalAriaHidden() {
|
|
return String(!this._isOpen);
|
|
}
|
|
|
|
get modalContainerClass() {
|
|
const sizeClass = this.size === 'large' ? 'slds-modal_large' :
|
|
this.size === 'small' ? 'slds-modal_small' : '';
|
|
return this._isOpen
|
|
? `slds-modal slds-fade-in-open ${sizeClass}`
|
|
: 'slds-modal slds-hidden';
|
|
}
|
|
|
|
get backdropClass() {
|
|
return this._isOpen
|
|
? 'slds-backdrop slds-backdrop_open'
|
|
: 'slds-backdrop';
|
|
}
|
|
|
|
get showHeader() {
|
|
return this.modalHeader || this.modalTagline;
|
|
}
|
|
|
|
// Lifecycle hooks
|
|
disconnectedCallback() {
|
|
// Clean up window event listener to prevent memory leaks
|
|
window.removeEventListener('keyup', this._boundHandleKeyUp);
|
|
}
|
|
|
|
// Private methods
|
|
_onOpen() {
|
|
// Collect all focusable elements within the modal
|
|
// Elements must have the 'focusable' class
|
|
this._focusableElements = [
|
|
...this.querySelectorAll('.focusable'),
|
|
...this.template.querySelectorAll(
|
|
'lightning-button, lightning-button-icon, lightning-input, button, [tabindex="0"]'
|
|
)
|
|
].filter(el => !el.disabled);
|
|
|
|
// Focus the first focusable element
|
|
this._focusFirstElement();
|
|
|
|
// Add window-level keyboard listener for ESC and Tab
|
|
window.addEventListener('keyup', this._boundHandleKeyUp);
|
|
|
|
// Dispatch open event
|
|
this.dispatchEvent(new CustomEvent('modalopen'));
|
|
}
|
|
|
|
_onClose() {
|
|
// Remove keyboard listener
|
|
window.removeEventListener('keyup', this._boundHandleKeyUp);
|
|
|
|
// Dispatch close event
|
|
this.dispatchEvent(new CustomEvent('modalclose'));
|
|
}
|
|
|
|
_handleKeyUp(event) {
|
|
if (event.code === ESCAPE_KEY || event.key === ESCAPE_KEY) {
|
|
this.close();
|
|
} else if (event.code === TAB_KEY || event.key === TAB_KEY) {
|
|
this._handleTabNavigation(event);
|
|
}
|
|
}
|
|
|
|
_handleTabNavigation(event) {
|
|
// Implement focus trap - keep focus within modal
|
|
if (this._focusableElements.length === 0) return;
|
|
|
|
const activeElement = this.template.activeElement || document.activeElement;
|
|
const lastIndex = this._focusableElements.length - 1;
|
|
const currentIndex = this._focusableElements.indexOf(activeElement);
|
|
|
|
if (event.shiftKey && currentIndex <= 0) {
|
|
// Shift+Tab on first element - wrap to last
|
|
event.preventDefault();
|
|
this._focusableElements[lastIndex]?.focus();
|
|
} else if (!event.shiftKey && currentIndex >= lastIndex) {
|
|
// Tab on last element - wrap to first
|
|
event.preventDefault();
|
|
this._focusFirstElement();
|
|
}
|
|
}
|
|
|
|
_focusFirstElement() {
|
|
// Small delay to ensure modal is fully rendered
|
|
// eslint-disable-next-line @lwc/lwc/no-async-operation
|
|
setTimeout(() => {
|
|
if (this._focusableElements.length > 0) {
|
|
this._focusableElements[0].focus();
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
// Event handlers
|
|
handleBackdropClick(event) {
|
|
// Close modal if clicking on the backdrop (outside modal content)
|
|
if (event.target.classList.contains(OUTER_MODAL_CLASS)) {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
handleCancel() {
|
|
this.close();
|
|
}
|
|
|
|
handleSave() {
|
|
// If a save handler was provided, call it
|
|
if (typeof this.modalSaveHandler === 'function') {
|
|
this.modalSaveHandler();
|
|
}
|
|
|
|
// Dispatch save event for parent components
|
|
this.dispatchEvent(new CustomEvent('modalsave'));
|
|
|
|
// Close the modal
|
|
this.close();
|
|
}
|
|
}
|