/**
* ct-modal-provider - Modal stack manager
*
* Provides ModalManager context to descendant ct-modal components.
* Manages modal stacking, z-index allocation, and global Escape key handling.
*
* @element ct-modal-provider
*
* @example
* ```html
*
*
*
* ...
*
*
* ```
*/
import { css, html, LitElement } from "lit";
import { provide } from "@lit/context";
import {
MODAL_BASE_Z_INDEX,
MODAL_Z_INDEX_INCREMENT,
modalContext,
type ModalManager,
type ModalRegistration,
} from "../modal-context.ts";
export class CTModalProvider extends LitElement {
static override styles = css`
:host {
display: contents;
}
`;
/** Provide the modal manager to descendants */
@provide({ context: modalContext })
private _manager: ModalManager = this._createManager();
/** Stack of registered modals (topmost is last) */
private _stack: ModalRegistration[] = [];
/** Counter for generating unique IDs */
private _nextId = 1;
/** Escape key handler reference for cleanup */
private _escapeHandler: ((e: KeyboardEvent) => void) | null = null;
/**
* Create the ModalManager implementation
*/
private _createManager(): ModalManager {
return {
register: (modal, dismissable) => this._register(modal, dismissable),
unregister: (id) => this._unregister(id),
isTopModal: (id) => this._isTopModal(id),
getStackDepth: () => this._stack.length,
requestCloseTop: () => this._requestCloseTop(),
};
}
override connectedCallback() {
super.connectedCallback();
// Set up global Escape key handler
this._escapeHandler = this._handleEscape.bind(this);
document.addEventListener("keydown", this._escapeHandler);
}
override disconnectedCallback() {
// Clean up Escape key handler
if (this._escapeHandler) {
document.removeEventListener("keydown", this._escapeHandler);
this._escapeHandler = null;
}
super.disconnectedCallback();
}
/**
* Handle global Escape key - close topmost dismissable modal
*/
private _handleEscape(e: KeyboardEvent) {
if (e.key === "Escape" && !e.defaultPrevented && this._stack.length > 0) {
if (this._requestCloseTop()) {
e.preventDefault();
e.stopPropagation();
}
}
}
/**
* Register a modal when it opens
*/
private _register(
modal: HTMLElement,
dismissable: boolean,
): ModalRegistration {
const id = `modal-${this._nextId++}`;
const zIndex = MODAL_BASE_Z_INDEX +
this._stack.length * MODAL_Z_INDEX_INCREMENT;
const registration: ModalRegistration = {
id,
element: modal,
dismissable,
zIndex,
};
this._stack.push(registration);
return registration;
}
/**
* Unregister a modal when it closes
*/
private _unregister(id: string): void {
const index = this._stack.findIndex((r) => r.id === id);
if (index >= 0) {
this._stack.splice(index, 1);
}
}
/**
* Check if a modal is the topmost in the stack
*/
private _isTopModal(id: string): boolean {
return (
this._stack.length > 0 && this._stack[this._stack.length - 1].id === id
);
}
/**
* Request close of the topmost dismissable modal
* Returns true if a modal was closed
*/
private _requestCloseTop(): boolean {
// Find topmost dismissable modal (search from top)
for (let i = this._stack.length - 1; i >= 0; i--) {
if (this._stack[i].dismissable) {
// Dispatch close event on the modal element
this._stack[i].element.dispatchEvent(
new CustomEvent("ct-modal-close", {
detail: { reason: "escape" },
bubbles: true,
composed: true,
}),
);
return true;
}
}
return false;
}
override render() {
return html`
`;
}
}
customElements.define("ct-modal-provider", CTModalProvider);