/* Modal dialog — opened via window.dispatchEvent('nv-modal', { title, body, buttons }). */ import { LitElement, html, css } from 'lit'; import { customElement, state } from 'lit/decorators.js'; interface ModalButton { label: string; variant?: 'ghost' | 'primary' | 'danger'; onClick?: () => void; } interface ModalReq { title: string; body: string; buttons?: ModalButton[]; } @customElement('nv-modal') export class NvModal extends LitElement { @state() private open = false; @state() private mTitle = ''; @state() private mBody = ''; @state() private buttons: ModalButton[] = []; static styles = css` :host { position: fixed; inset: 0; background: rgba(0,0,0,0.55); backdrop-filter: blur(4px); z-index: 200; display: grid; place-items: center; opacity: 0; pointer-events: none; transition: opacity 0.18s; } :host([open]) { opacity: 1; pointer-events: auto; } .modal { background: var(--bg-1); border: 1px solid var(--line-2); border-radius: var(--radius); box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7); width: min(520px, 92vw); max-height: 86vh; display: flex; flex-direction: column; transform: translateY(12px) scale(0.98); transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1); } :host([open]) .modal { transform: translateY(0) scale(1); } .h { padding: 14px 16px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; } .h .ttl { font-size: 14px; font-weight: 600; } .body { padding: 16px; overflow-y: auto; font-size: 13px; color: var(--ink-2); line-height: 1.55; } .f { padding: 12px 16px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; } button { padding: 6px 12px; border-radius: 8px; font-size: 12.5px; cursor: pointer; font-family: inherit; border: 1px solid var(--line); background: var(--bg-2); color: var(--ink); } button.ghost { background: transparent; } button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; } button.danger { background: var(--bad); border-color: var(--bad); color: #fff; } .close { width: 28px; height: 28px; background: transparent; border: 1px solid var(--line); border-radius: 6px; color: var(--ink-2); } `; override connectedCallback(): void { super.connectedCallback(); window.addEventListener('nv-modal', this.onModal as EventListener); window.addEventListener('keydown', this.onKey); } override disconnectedCallback(): void { super.disconnectedCallback(); window.removeEventListener('nv-modal', this.onModal as EventListener); window.removeEventListener('keydown', this.onKey); } private onModal = (e: Event): void => { const r = (e as CustomEvent).detail as ModalReq; this.mTitle = r.title; this.mBody = r.body; this.buttons = r.buttons ?? [{ label: 'Close', variant: 'primary' }]; this.open = true; this.setAttribute('open', ''); // a11y: focus the first interactive element inside the modal so keyboard // users land in the dialog rather than behind it. Light focus trap via // the keydown handler below catches Tab cycling. requestAnimationFrame(() => { const root = this.shadowRoot; if (!root) return; const first = root.querySelector('input, select, textarea, button:not(.close)'); first?.focus(); }); }; override updated(): void { if (!this.open) return; const root = this.shadowRoot; if (!root) return; // Trap Tab inside the modal while open. const trap = (e: KeyboardEvent): void => { if (e.key !== 'Tab') return; const focusables = Array.from( root.querySelectorAll('input, select, textarea, button, [href]'), ).filter((el) => !el.hasAttribute('disabled')); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; const active = (root.activeElement as HTMLElement | null) ?? null; if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } }; root.removeEventListener('keydown', trap as EventListener); root.addEventListener('keydown', trap as EventListener); } private onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape' && this.open) this.close(); }; private close(): void { this.open = false; this.removeAttribute('open'); } private clickBtn(b: ModalButton): void { b.onClick?.(); this.close(); } override render() { return html` `; } } export function openModal(req: ModalReq): void { window.dispatchEvent(new CustomEvent('nv-modal', { detail: req })); }