import { LitElement, html, css, CSSResult, TemplateResult, PropertyValues, } from 'lit'; import { property, customElement, query } from 'lit/decorators.js'; import Modal from './shoelace/modal'; import { getDeepestActiveElement } from './shoelace/active-elements'; import './modal-template'; import { ModalTemplate } from './modal-template'; import { ModalConfig } from './modal-config'; import { ModalManagerHostBridge } from './modal-manager-host-bridge'; import { ModalManagerInterface } from './modal-manager-interface'; import { ModalManagerHostBridgeInterface } from './modal-manager-host-bridge-interface'; import { ModalManagerMode } from './modal-manager-mode'; @customElement('modal-manager') export class ModalManager extends LitElement implements ModalManagerInterface { /** * The current mode of the ModalManager * * Current options are `modal` or `closed` * * @type {ModalManagerMode} * @memberof ModalManager */ @property({ type: String, reflect: true }) mode: ModalManagerMode = ModalManagerMode.Closed; /** * Custom content to display in the modal's content slot * * @type {(TemplateResult | undefined)} * @memberof ModalManager */ @property({ type: Object }) customModalContent?: TemplateResult; /** * This hostBridge handles environmental-specific interactions such as adding classes * to the body tag or event listeners needed to support the modal manager in the host environment. * * There is a default `ModalManagerHostBridge`, but consumers can override it with a custom * `ModalManagerHostBridgeInterface` * * @type {ModalManagerHostBridgeInterface} * @memberof ModalManager */ @property({ type: Object }) hostBridge: ModalManagerHostBridgeInterface = new ModalManagerHostBridge( this, ); /** * Reference to the ModalTemplate DOM element * * @private * @type {ModalTemplate} * @memberof ModalManager */ @query('modal-template') private modalTemplate?: ModalTemplate; // Imported tab handling from shoelace public modal = new Modal(this); async firstUpdated(): Promise { // Give the browser a chance to paint await new Promise((r) => setTimeout(r, 0)); if (this.closeOnBackdropClick) { this.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') { this.backdropClicked(); } }); } } disconnectedCallback() { super.disconnectedCallback(); this.modal.deactivate(); } /** @inheritdoc */ render(): TemplateResult { return html`
${this.customModalContent}
`; } /** @inheritdoc */ getMode(): ModalManagerMode { return this.mode; } /** @inheritdoc */ closeModal(): void { this.mode = ModalManagerMode.Closed; this.customModalContent = undefined; if (this.modalTemplate) this.modalTemplate.config = new ModalConfig(); this.modal.deactivate(); // Return focus to the triggering element, if possible (this.triggeringElement as HTMLElement)?.focus?.(); this.triggeringElement = undefined; } /** * Whether the modal should close if the user taps on the backdrop * * @private * @memberof ModalManager */ private closeOnBackdropClick = true; /** * The element that had focus when the modal was opened, so that we can return focus * to it after the modal closes. */ private triggeringElement?: Element; /** * A callback if the user closes the modal * * @private * @memberof ModalManager */ private userClosedModalCallback?: () => void; /** * A callback if the user presses the left nav button * * @private * @memberof ModalManager */ private userPressedLeftNavButtonCallback?: () => void; /** * Call the userClosedModalCallback and reset it if it exists * * @private * @memberof ModalManager */ private callUserClosedModalCallback(): void { // we assign the callback to a temp var and undefine it before calling it // otherwise, we run into the potential for an infinite loop if the // callback triggers another `showModal()`, which would execute `userClosedModalCallback` const callback = this.userClosedModalCallback; this.userClosedModalCallback = undefined; if (callback) callback(); } /** * Call the user pressed left nav button callback and reset it if it exists * * @private * @memberof ModalManager */ private callUserPressedLeftNavButtonCallback(): void { // avoids an infinite showModal() loop, as above const callback = this.userPressedLeftNavButtonCallback; this.userPressedLeftNavButtonCallback = undefined; if (callback) callback(); } /** @inheritdoc */ async showModal(options: { config: ModalConfig; customModalContent?: TemplateResult; userClosedModalCallback?: () => void; userPressedLeftNavButtonCallback?: () => void; }): Promise { // If the dialog is being opened, make note of what element was focused beforehand if (this.mode === ModalManagerMode.Closed) this.captureFocusedElement(); this.closeOnBackdropClick = options.config.closeOnBackdropClick; this.userClosedModalCallback = options.userClosedModalCallback; this.userPressedLeftNavButtonCallback = options.userPressedLeftNavButtonCallback; this.customModalContent = options.customModalContent; this.mode = ModalManagerMode.Open; if (this.modalTemplate) { this.modalTemplate.config = options.config; await this.modalTemplate.updateComplete; this.modalTemplate.focus(); } this.modal.activate(); } /** * Sets the triggering element to the one that is currently focused, as deep * within Shadow DOM as possible. */ private captureFocusedElement(): void { this.triggeringElement = getDeepestActiveElement(); } /** @inheritdoc */ updated(changed: PropertyValues): void { /* istanbul ignore else */ if (changed.has('mode')) { this.handleModeChange(); } } /** * Called when the backdrop is clicked * * @private * @memberof ModalManager */ private backdropClicked(): void { if (this.closeOnBackdropClick) { this.closeModal(); this.callUserClosedModalCallback(); } } /** * Handle the mode change * * @private * @memberof ModalManager */ private handleModeChange(): void { this.hostBridge.handleModeChange(this.mode); this.emitModeChangeEvent(); } /** * Emit a modeChange event * * @private * @memberof ModalManager */ private emitModeChangeEvent(): void { const event = new CustomEvent('modeChanged', { detail: { mode: this.mode }, }); this.dispatchEvent(event); } /** * Called when the modal close button is pressed. Closes the modal. * * @private * @memberof ModalManager */ private closeButtonPressed(): void { this.closeModal(); this.callUserClosedModalCallback(); } /** @inheritdoc */ static get styles(): CSSResult { const modalBackdropColor = css`var(--modalBackdropColor, rgba(10, 10, 10, 0.9))`; const modalBackdropZindex = css`var(--modalBackdropZindex, 1000)`; const modalWidth = css`var(--modalWidth, 32rem)`; const modalMaxWidth = css`var(--modalMaxWidth, 95%)`; const modalZindex = css`var(--modalZindex, 2000)`; return css` .container { width: 100%; height: 100%; } .backdrop { position: fixed; top: 0; left: 0; background-color: ${modalBackdropColor}; width: 100%; height: 100%; z-index: ${modalBackdropZindex}; } modal-template { outline: 0; position: fixed; top: 0; left: 50%; transform: translate(-50%, 0); z-index: ${modalZindex}; width: ${modalWidth}; max-width: ${modalMaxWidth}; } `; } }