import { html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { BootstrapElement, FocusTrapController, defineElement } from '@bootstrap-wc/core'; export type OffcanvasPlacement = 'start' | 'end' | 'top' | 'bottom'; export type OffcanvasResponsiveBreakpoint = 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; /** * `` — Bootstrap offcanvas (side drawer). * * @fires bs-show / bs-shown / bs-hide / bs-hidden */ export class BsOffcanvas extends BootstrapElement { @property({ type: Boolean, reflect: true }) open = false; @property({ type: String }) placement: OffcanvasPlacement = 'start'; @property({ type: String, attribute: 'heading' }) heading?: string; @property({ type: Boolean, attribute: 'body-scroll' }) bodyScroll = false; @property({ type: Boolean, attribute: 'no-backdrop' }) noBackdrop = false; @property({ type: Boolean, attribute: 'static-backdrop' }) staticBackdrop = false; @property({ type: Boolean, attribute: 'no-close-button' }) noCloseButton = false; @property({ type: Boolean }) dark = false; /** * When set to one of `sm|md|lg|xl|xxl`, renders `.offcanvas-{bp}` so the * panel is hidden as an offcanvas only below that breakpoint and becomes * an inline column at/above it (Bootstrap "Responsive" variant). */ @property({ type: String }) responsive?: OffcanvasResponsiveBreakpoint; /** * Render the panel inline (`position: static`) instead of as a fixed * drawer. Suppresses the backdrop and the focus trap. Use for in-page * documentation / reference rendering of the panel chrome — Bootstrap's * "Offcanvas components" docs use the same pattern. */ @property({ type: Boolean, attribute: 'static-display' }) staticDisplay = false; @query('[part="panel"]') private _panel!: HTMLElement; private _trap = new FocusTrapController(this); override connectedCallback() { super.connectedCallback(); document.addEventListener('keydown', this._onKeydown); } override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('keydown', this._onKeydown); this._trap.deactivate(); } override updated(changed: Map) { super.updated(changed); if (changed.has('open')) { if (this.open) { this.dispatchEvent(new CustomEvent('bs-show', { bubbles: true, composed: true })); queueMicrotask(() => this._panel && this._trap.activate(this._panel)); setTimeout( () => this.dispatchEvent(new CustomEvent('bs-shown', { bubbles: true, composed: true })), 300, ); } else { this._trap.deactivate(); this.dispatchEvent(new CustomEvent('bs-hide', { bubbles: true, composed: true })); setTimeout( () => this.dispatchEvent(new CustomEvent('bs-hidden', { bubbles: true, composed: true })), 300, ); } } } show() { this.open = true; } hide() { this.open = false; } toggle() { this.open = !this.open; } private _onKeydown = (ev: KeyboardEvent) => { if (!this.open) return; if (ev.key === 'Escape') this.hide(); }; private _onBackdropClick = () => { if (!this.staticBackdrop) this.hide(); }; override render() { const base = this.responsive ? `offcanvas-${this.responsive}` : 'offcanvas'; const panelClasses = classMap({ [base]: true, [`offcanvas-${this.placement}`]: true, 'text-bg-dark': this.dark, show: this.open || this.staticDisplay, }); const showBackdrop = !this.noBackdrop && !this.responsive && !this.staticDisplay && this.open; const panelStyle = this.staticDisplay ? 'position: static; visibility: visible; transform: none;' : this.open || this.responsive ? 'visibility: visible' : ''; return html` ${showBackdrop ? html`` : nothing} ${this.heading || !this.noCloseButton ? html` ${this.heading ? html`${this.heading}` : html``} ${this.noCloseButton ? nothing : html` this.hide()} >`} ` : nothing} `; } } defineElement('bs-offcanvas', BsOffcanvas); declare global { interface HTMLElementTagNameMap { 'bs-offcanvas': BsOffcanvas; } }