/**
* Quick Panel Shell
*
* A unified panel container that hosts multiple views:
* - `search`: the launcher/search UI (Phase 1+)
* - `chat`: AI Chat view (existing capability)
*
* The shell owns the overlay + glass panel layout and provides isolated
* mount points per-view for header/content/footer sections.
*/
import { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';
import type { QuickPanelView } from '../core/types';
// SVG Icons
const ICON_CLOSE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>`;
// ============================================================
// Types
// ============================================================
export interface QuickPanelShellElements {
/** Overlay backdrop */
overlay: HTMLDivElement;
/** Main panel container */
panel: HTMLDivElement;
/** Header section */
header: HTMLDivElement;
headerLeft: HTMLDivElement;
headerRight: HTMLDivElement;
closeBtn: HTMLButtonElement;
/** View-specific header mounts */
headerSearchMount: HTMLDivElement;
headerChatMount: HTMLDivElement;
headerRightSearchMount: HTMLDivElement;
headerRightChatMount: HTMLDivElement;
/** Content section */
content: HTMLDivElement;
contentSearchMount: HTMLDivElement;
contentChatMount: HTMLDivElement;
/** Footer section */
footer: HTMLDivElement;
footerSearchMount: HTMLDivElement;
footerChatMount: HTMLDivElement;
}
export interface QuickPanelShellOptions {
/** Shadow DOM mount point (typically `elements.root` from shadow-host.ts) */
mount: HTMLElement;
/** Default view on mount. Default: `search` */
defaultView?: QuickPanelView;
/** Accessible label for the dialog. Default: "Quick Panel" */
ariaLabel?: string;
/** Close when clicking the backdrop. Default: true */
closeOnBackdropClick?: boolean;
/** Called when close is requested (button/backdrop/api) */
onRequestClose?: (reason: 'button' | 'backdrop' | 'api') => void;
/** Called after view changes */
onViewChange?: (view: QuickPanelView) => void;
}
export interface QuickPanelShellManager {
/** Get shell elements (null if disposed) */
getElements: () => QuickPanelShellElements | null;
/** Get current view */
getView: () => QuickPanelView;
/** Switch to a different view */
setView: (view: QuickPanelView) => void;
/** Request panel close */
requestClose: (reason?: 'button' | 'backdrop' | 'api') => void;
/** Clean up resources */
dispose: () => void;
}
// ============================================================
// Constants
// ============================================================
const DEFAULT_ARIA_LABEL = 'Quick Panel';
const DEFAULT_VIEW: QuickPanelView = 'search';
// ============================================================
// Main Factory
// ============================================================
/**
* Mount the Quick Panel shell.
*
* @example
* ```typescript
* const shell = mountQuickPanelShell({
* mount: shadowHostElements.root,
* defaultView: 'search',
* onRequestClose: () => quickPanel.hide(),
* });
*
* // Get mount points for search view
* const elements = shell.getElements();
* if (elements) {
* // Mount search input to elements.headerSearchMount
* // Mount results to elements.contentSearchMount
* }
*
* // Switch to chat view
* shell.setView('chat');
*
* // Cleanup
* shell.dispose();
* ```
*/
export function mountQuickPanelShell(options: QuickPanelShellOptions): QuickPanelShellManager {
const disposer = new Disposer();
const mount = options.mount;
const closeOnBackdropClick = options.closeOnBackdropClick ?? true;
let disposed = false;
let elements: QuickPanelShellElements | null = null;
let currentView: QuickPanelView = options.defaultView ?? DEFAULT_VIEW;
// Best-effort cleanup (crash recovery / duplicate mounts)
try {
const existing = mount.querySelector?.('[data-mcp-quick-panel-shell="true"]');
if (existing instanceof HTMLElement) {
existing.remove();
}
} catch {
// Ignore cleanup errors
}
// --------------------------------------------------------
// DOM Construction
// --------------------------------------------------------
const overlay = document.createElement('div');
overlay.className = 'qp-overlay';
overlay.setAttribute('data-mcp-quick-panel-shell', 'true');
const panel = document.createElement('div');
panel.className = 'qp-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
panel.setAttribute('aria-label', options.ariaLabel?.trim() || DEFAULT_ARIA_LABEL);
panel.dataset.qpView = currentView;
// Header
const header = document.createElement('div');
header.className = 'qp-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'qp-header-left';
const headerSearchMount = document.createElement('div');
headerSearchMount.className = 'qp-header-mount qp-header-mount--search';
const headerChatMount = document.createElement('div');
headerChatMount.className = 'qp-header-mount qp-header-mount--chat';
headerLeft.append(headerSearchMount, headerChatMount);
const headerRight = document.createElement('div');
headerRight.className = 'qp-header-right';
const headerRightSearchMount = document.createElement('div');
headerRightSearchMount.className = 'qp-header-right-mount qp-header-right-mount--search';
const headerRightChatMount = document.createElement('div');
headerRightChatMount.className = 'qp-header-right-mount qp-header-right-mount--chat';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'qp-icon-btn ac-focus-ring';
closeBtn.innerHTML = ICON_CLOSE;
closeBtn.setAttribute('aria-label', 'Close Quick Panel');
headerRight.append(headerRightSearchMount, headerRightChatMount, closeBtn);
header.append(headerLeft, headerRight);
// Content
const content = document.createElement('div');
content.className = 'qp-content ac-scroll';
const contentSearchMount = document.createElement('div');
contentSearchMount.className = 'qp-content-mount qp-content-mount--search';
const contentChatMount = document.createElement('div');
contentChatMount.className = 'qp-content-mount qp-content-mount--chat';
content.append(contentSearchMount, contentChatMount);
// Footer (reuse `.qp-composer` for consistent glass divider/padding)
const footer = document.createElement('div');
footer.className = 'qp-composer';
const footerSearchMount = document.createElement('div');
footerSearchMount.className = 'qp-footer-mount qp-footer-mount--search';
const footerChatMount = document.createElement('div');
footerChatMount.className = 'qp-footer-mount qp-footer-mount--chat';
footer.append(footerSearchMount, footerChatMount);
// Assemble
panel.append(header, content, footer);
overlay.append(panel);
mount.append(overlay);
disposer.add(() => overlay.remove());
elements = {
overlay,
panel,
header,
headerLeft,
headerRight,
closeBtn,
headerSearchMount,
headerChatMount,
headerRightSearchMount,
headerRightChatMount,
content,
contentSearchMount,
contentChatMount,
footer,
footerSearchMount,
footerChatMount,
};
// --------------------------------------------------------
// View Switching
// --------------------------------------------------------
function renderView(view: QuickPanelView): void {
if (!elements) return;
elements.panel.dataset.qpView = view;
// Search view visibility
const isSearch = view === 'search';
elements.headerSearchMount.hidden = !isSearch;
elements.headerRightSearchMount.hidden = !isSearch;
elements.contentSearchMount.hidden = !isSearch;
elements.footerSearchMount.hidden = !isSearch;
// Chat view visibility
const isChat = view === 'chat';
elements.headerChatMount.hidden = !isChat;
elements.headerRightChatMount.hidden = !isChat;
elements.contentChatMount.hidden = !isChat;
elements.footerChatMount.hidden = !isChat;
}
function setView(view: QuickPanelView): void {
if (disposed) return;
if (view !== 'search' && view !== 'chat') return;
if (view === currentView) return;
currentView = view;
renderView(currentView);
try {
options.onViewChange?.(currentView);
} catch {
// Best-effort callback
}
}
// Apply initial visibility
renderView(currentView);
// --------------------------------------------------------
// Close Handling
// --------------------------------------------------------
function requestClose(reason: 'button' | 'backdrop' | 'api' = 'api'): void {
if (disposed) return;
try {
options.onRequestClose?.(reason);
} catch {
// Best-effort: caller owns lifecycle
}
}
disposer.listen(closeBtn, 'click', () => requestClose('button'));
if (closeOnBackdropClick) {
disposer.listen(overlay, 'click', (ev: MouseEvent) => {
if (disposed) return;
if (ev.target === overlay) {
requestClose('backdrop');
}
});
}
// --------------------------------------------------------
// Public API
// --------------------------------------------------------
return {
getElements: () => elements,
getView: () => currentView,
setView,
requestClose,
dispose: () => {
if (disposed) return;
disposed = true;
elements = null;
disposer.dispose();
},
};
}