import { html, nothing } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { BootstrapElement, FocusTrapController, defineElement } from '@bootstrap-wc/core'; export type ModalSize = 'sm' | 'md' | 'lg' | 'xl'; export type ModalFullscreen = | boolean | '' | 'sm-down' | 'md-down' | 'lg-down' | 'xl-down' | 'xxl-down'; // Accepts: absent → false, empty string / "true" → true, breakpoint token // (e.g. "sm-down") → string. Mirrors Bootstrap's `.modal-fullscreen` vs // `.modal-fullscreen-sm-down` class set. const fullscreenConverter = { fromAttribute(value: string | null): ModalFullscreen { if (value === null) return false; if (value === '' || value === 'true') return true; if (value === 'false') return false; return value as ModalFullscreen; }, toAttribute(value: ModalFullscreen): string | null { if (value === false || value == null) return null; if (value === true) return ''; return String(value); }, }; /** * `` — Bootstrap modal dialog with backdrop, focus trap, and ESC close. * * @slot - Modal body. * @slot title - Rendered inside `.modal-title`. * @slot footer - Rendered inside `.modal-footer`. * @fires bs-show / bs-shown / bs-hide / bs-hidden */ export class BsModal extends BootstrapElement { @property({ type: Boolean, reflect: true }) open = false; @property({ type: String, attribute: 'heading' }) heading?: string; @property({ type: String }) size: ModalSize = 'md'; @property({ type: Boolean }) centered = false; @property({ type: Boolean }) scrollable = false; @property({ type: Boolean, attribute: 'static-backdrop' }) staticBackdrop = false; @property({ type: Boolean, attribute: 'no-backdrop' }) noBackdrop = false; @property({ type: Boolean, attribute: 'no-close-on-escape' }) noCloseOnEscape = false; @property({ type: Boolean, attribute: 'no-close-button' }) noCloseButton = false; /** * Fullscreen mode. * - `true` → `.modal-fullscreen` (always fullscreen) * - `"sm-down"` etc. → `.modal-fullscreen-{breakpoint}-down` * - `false`/absent → regular modal */ @property({ converter: fullscreenConverter, reflect: true }) fullscreen: ModalFullscreen = false; /** * Render the dialog inline (not fixed-positioned) for static docs previews. * Adds `.position-static .d-block` and suppresses the backdrop so the * markup can sit in normal document flow. Has no effect on focus trap or * show/hide lifecycle. */ @property({ type: Boolean, attribute: 'static-preview' }) staticPreview = false; /** * Alias for `static-preview`. Both `static-display` and `static-preview` * map to the same internal state — the static, no-backdrop, no-focus-trap * render mode. */ @property({ type: Boolean, attribute: 'static-display' }) staticDisplay = false; /** Extra classes for the outer `.modal` element (e.g. `modal-sheet bg-body-secondary p-4`). */ @property({ type: String, attribute: 'modal-class' }) modalClass = ''; /** Extra classes for the `.modal-content` wrapper (e.g. `rounded-4 shadow`). */ @property({ type: String, attribute: 'content-class' }) contentClass = ''; /** Extra classes for the `.modal-header` (e.g. `border-bottom-0`). */ @property({ type: String, attribute: 'header-class' }) headerClass = ''; /** Extra classes for the `.modal-body` (e.g. `py-0 p-4 text-center`). */ @property({ type: String, attribute: 'body-class' }) bodyClass = ''; /** Extra classes for the `.modal-footer` (e.g. `flex-column gap-2 border-top-0`). */ @property({ type: String, attribute: 'footer-class' }) footerClass = ''; @state() private _animating = false; @query('.modal') private _modal!: HTMLElement; @query('.modal-dialog') private _dialog!: HTMLElement; private _focusTrap = new FocusTrapController(this); private _prevOverflow = ''; private _originalParent: Node | null = null; private _originalNextSibling: Node | null = null; override connectedCallback() { super.connectedCallback(); document.addEventListener('keydown', this._onKeydown); if (this.staticPreview) this._applyStaticAttrs(); } private _applyStaticAttrs(): void { if (!this.hasAttribute('role')) this.setAttribute('role', 'dialog'); if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '-1'); this.setAttribute('aria-modal', 'false'); } override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('keydown', this._onKeydown); this._focusTrap.deactivate(); // NB: don't reset `_originalParent` here — a teleport-to-body triggers a // disconnect → reconnect pair while we're mid-move, and we still want to // be able to restore the original location on close. if (!this._teleporting) this._restoreBody(); } private _teleporting = false; /** * Move the host into `document.body` so the `.modal` overlay escapes every * ancestor stacking context (Starlight's `.main-pane` uses `isolation: * isolate`, for example, which otherwise caps the z-index of a modal * rendered in-place). Static-preview modals intentionally stay inline. */ private _teleportToBody(): void { if (this.staticPreview) return; if (this.parentElement === document.body) return; this._originalParent = this.parentNode; this._originalNextSibling = this.nextSibling; this._teleporting = true; document.body.appendChild(this); this._teleporting = false; } /** Restore the host to its author-placed position in the DOM. */ private _restoreFromBody(): void { if (!this._originalParent) return; const parent = this._originalParent; const next = this._originalNextSibling; this._originalParent = null; this._originalNextSibling = null; this._teleporting = true; if (next && next.parentNode === parent) parent.insertBefore(this, next); else parent.appendChild(this); this._teleporting = false; } override willUpdate(changed: Map) { super.willUpdate(changed); // Mirror the static-display alias onto staticPreview (and vice versa) // so authors can use either attribute interchangeably. if (changed.has('staticDisplay') && this.staticDisplay !== this.staticPreview) { this.staticPreview = this.staticDisplay; } else if (changed.has('staticPreview') && this.staticPreview !== this.staticDisplay) { this.staticDisplay = this.staticPreview; } } override updated(changed: Map) { super.updated(changed); if (changed.has('open')) { if (this.open) void this._onOpen(); else void this._onClose(); } } show() { this.open = true; } hide() { this.open = false; } toggle() { this.open = !this.open; } private async _onOpen() { this.dispatchEvent(new CustomEvent('bs-show', { bubbles: true, composed: true, cancelable: true })); this._animating = true; this._prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; document.body.classList.add('modal-open'); this._teleportToBody(); await this.updateComplete; await new Promise((r) => requestAnimationFrame(r)); this._animating = false; this.requestUpdate(); await this.updateComplete; if (this._dialog) this._focusTrap.activate(this._dialog); this.dispatchEvent(new CustomEvent('bs-shown', { bubbles: true, composed: true })); } private async _onClose() { this.dispatchEvent(new CustomEvent('bs-hide', { bubbles: true, composed: true, cancelable: true })); this._focusTrap.deactivate(); this._animating = true; await this.updateComplete; await new Promise((r) => setTimeout(r, 150)); this._animating = false; this._restoreBody(); this._restoreFromBody(); this.dispatchEvent(new CustomEvent('bs-hidden', { bubbles: true, composed: true })); } private _restoreBody() { document.body.style.overflow = this._prevOverflow; document.body.classList.remove('modal-open'); } private _onKeydown = (ev: KeyboardEvent) => { if (!this.open || this.noCloseOnEscape) return; if (ev.key === 'Escape') this.hide(); }; /** Convert a `"foo bar"` string into a `{foo: true, bar: true}` map. */ private _extraClasses(s: string): Record { const out: Record = {}; if (!s) return out; for (const c of s.split(/\s+/)) if (c) out[c] = true; return out; } /** * In `static-display` mode the host IS the visible `.modal` chrome — mirror * `.modal.show.position-static.d-block` (plus any `modal-class` extras) * onto the host so layout rules in the page can target it. */ protected override hostClasses(): string { if (!this.staticPreview) return ''; const parts = ['modal', 'show', 'position-static', 'd-block']; if (this.modalClass) parts.push(this.modalClass); return parts.join(' '); } private _onBackdropClick = (ev: MouseEvent) => { if (this.staticPreview) return; if (ev.target === this._modal && !this.staticBackdrop) this.hide(); }; override render() { const fullscreenClass = this.fullscreen === true ? 'modal-fullscreen' : typeof this.fullscreen === 'string' && this.fullscreen ? `modal-fullscreen-${this.fullscreen}` : ''; const dialogClasses = classMap({ 'modal-dialog': true, [`modal-${this.size}`]: this.size !== 'md', 'modal-dialog-centered': this.centered, 'modal-dialog-scrollable': this.scrollable, [fullscreenClass]: !!fullscreenClass, }); const isShown = this.staticPreview || (this.open && !this._animating); const modalClasses = classMap({ modal: true, fade: !this.staticPreview, show: isShown, 'position-static': this.staticPreview, 'd-block': this.staticPreview, ...this._extraClasses(this.modalClass), }); const hasHeader = !!this.heading || this.querySelector('[slot="title"]') !== null || !this.noCloseButton; const hasFooter = this.querySelector('[slot="footer"]') !== null; const showBackdrop = !this.noBackdrop && !this.staticPreview && (this.open || this._animating); // Only force `display: block` when the modal is actually visible; otherwise // leave it to Bootstrap's default (`.modal { display: none; }`) so closed // modals don't render an invisible full-viewport overlay. const modalStyle = isShown || this._animating ? 'display: block' : 'display: none'; // Inline shadow stylesheet that replays Bootstrap's `.modal-footer > *` // gap rule for slotted children — Bootstrap's selector targets direct // children of the shadow `.modal-footer` and so only sees the ``, // not the light-DOM buttons projected through it. const slottedFooterMargin = html``; const dialogTree = html`${slottedFooterMargin}
${hasHeader ? html`

${this.heading ?? html``}

${this.noCloseButton ? nothing : html``}
` : nothing}
${hasFooter ? html`
` : nothing}
`; if (this.staticPreview) { // Host already carries `.modal.show.position-static.d-block` (+ modalClass) // via hostClasses() — render only the inner dialog tree to avoid a // duplicated `.modal` wrapper in shadow. return dialogTree; } return html` ${showBackdrop ? html`` : nothing} `; } } defineElement('bs-modal', BsModal); declare global { interface HTMLElementTagNameMap { 'bs-modal': BsModal; } }