/** * Global search command-palette dialog. * * Opens via Ctrl+K / Cmd+K or topbar trigger. Fetches live results from the * preview API and renders them grouped by category with keyboard navigation. */ import { warnOnce, resolveHost } from '../core/app-dom.js'; import { getJson } from '../core/app-http.js'; import { isEditableTarget } from '../core/app-form-utils.js'; const ICON_MAP = { 'users': 'bi-person-badge', 'tenants': 'bi-buildings', 'departments': 'bi-diagram-3', 'roles': 'bi-people', 'permissions': 'bi-shield-lock', 'scheduled-jobs': 'bi-calendar-check', 'pages': 'bi-file-text', 'mail-log': 'bi-envelope-paper', 'docs': 'bi-journal-text', 'hotkeys': 'bi-keyboard', 'contacts': 'bi-person-lines-fill', 'bookmarks': 'bi-bookmark', 'api-audit': 'bi-journal-code', 'system-audit': 'bi-journal-check', }; export function initGlobalSearch(root = document, options = {}) { const host = resolveHost(root); const dialog = host.matches?.('[data-app-search-dialog]') ? host : host.querySelector('[data-app-search-dialog]'); if (!dialog) {return { destroy: () => {} };} if (dialog.dataset.globalSearchBound === '1' && dialog._globalSearchApi) { return dialog._globalSearchApi; } dialog.dataset.globalSearchBound = '1'; const input = dialog.querySelector('#search-dialog-input'); const resultsEl = dialog.querySelector('[data-search-dialog-results]'); const emptyStateEl = dialog.querySelector('[data-search-dialog-empty]'); const loadingEl = dialog.querySelector('[data-search-dialog-loading]'); const errorEl = dialog.querySelector('[data-search-dialog-error]'); const footerEl = dialog.querySelector('[data-search-dialog-footer]'); const viewAllLink = dialog.querySelector('[data-search-dialog-view-all]'); const closeButton = dialog.querySelector('[data-app-search-dialog-close]'); const triggerButton = document.querySelector('[data-app-search-trigger]'); const triggerShortcutEl = document.querySelector('[data-search-trigger-shortcut]'); const appBase = String(options.appBase || document.baseURI || window.location.origin).trim() || document.baseURI; const endpointAttr = dialog.getAttribute('data-search-endpoint') || 'admin/search/data'; const resultsPageAttr = dialog.getAttribute('data-search-results-page') || 'search'; const endpoint = new URL(endpointAttr, appBase); const resultsPage = new URL(resultsPageAttr, appBase); const minLength = 2; let pending = null; let activeIndex = -1; let flatItems = []; let abortController = null; const shortcutLabel = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent) ? '⌘K' : 'Ctrl+K'; if (triggerShortcutEl) {triggerShortcutEl.textContent = shortcutLabel;} // --- State management --- const setState = (state) => { if (emptyStateEl) {emptyStateEl.hidden = state !== 'empty';} if (loadingEl) {loadingEl.hidden = state !== 'loading';} if (errorEl) {errorEl.hidden = state !== 'error';} if (resultsEl) {resultsEl.hidden = state !== 'success';} if (footerEl) {footerEl.hidden = state !== 'success';} }; const clearHighlight = () => { activeIndex = -1; resultsEl.querySelectorAll('[role="option"]').forEach((el) => { el.removeAttribute('aria-selected'); el.classList.remove('app-search-dialog-item-active'); }); if (input) {input.removeAttribute('aria-activedescendant');} }; const setHighlight = (index) => { clearHighlight(); if (index < 0 || index >= flatItems.length) {return;} activeIndex = index; const item = flatItems[index]; item.setAttribute('aria-selected', 'true'); item.classList.add('app-search-dialog-item-active'); item.scrollIntoView({ block: 'nearest' }); if (input) {input.setAttribute('aria-activedescendant', item.id);} }; // --- Rendering --- const iconForKey = (key) => ICON_MAP[key] || 'bi-search'; const render = (categories, query) => { resultsEl.innerHTML = ''; flatItems = []; let itemIndex = 0; let hasResults = false; categories.forEach((category) => { if (category.count === 0 && (!category.items || category.items.length === 0)) {return;} hasResults = true; const group = document.createElement('li'); group.setAttribute('role', 'presentation'); group.classList.add('app-search-dialog-category'); const heading = document.createElement('div'); heading.classList.add('app-search-dialog-category-heading'); heading.textContent = category.label || category.key; group.appendChild(heading); const list = document.createElement('ul'); list.classList.add('app-search-dialog-category-items'); list.setAttribute('role', 'group'); if (Array.isArray(category.items) && category.items.length > 0) { category.items.forEach((item) => { if (!item || !item.url) {return;} const li = document.createElement('li'); li.setAttribute('role', 'option'); li.setAttribute('id', `search-item-${itemIndex}`); li.classList.add('app-search-dialog-item'); li.dataset.searchUrl = item.url; const link = document.createElement('a'); link.href = item.url; link.tabIndex = -1; const itemIcon = item.icon || iconForKey(category.key); const icon = document.createElement('i'); icon.className = `bi ${safeCssClass(itemIcon)} app-search-dialog-item-icon`; icon.setAttribute('aria-hidden', 'true'); link.appendChild(icon); const textWrap = document.createElement('span'); textWrap.classList.add('app-search-dialog-item-text'); const titleEl = document.createElement('span'); titleEl.classList.add('app-search-dialog-item-title'); highlightText(titleEl, item.label || '', query); textWrap.appendChild(titleEl); if (item.subtitle) { const subtitleEl = document.createElement('span'); subtitleEl.classList.add('app-search-dialog-item-subtitle'); highlightText(subtitleEl, item.subtitle, query); textWrap.appendChild(subtitleEl); } link.appendChild(textWrap); li.appendChild(link); list.appendChild(li); flatItems.push(li); itemIndex++; }); } if (category.url && category.count > (category.items?.length || 0)) { const viewLi = document.createElement('li'); viewLi.classList.add('app-search-dialog-item', 'app-search-dialog-item-view-all'); const viewLink = document.createElement('a'); viewLink.href = category.url; viewLink.tabIndex = -1; const viewIcon = document.createElement('i'); viewIcon.className = 'bi bi-arrow-right app-search-dialog-item-icon'; viewIcon.setAttribute('aria-hidden', 'true'); viewLink.appendChild(viewIcon); const viewText = document.createElement('span'); viewText.classList.add('app-search-dialog-item-text'); const viewTitle = document.createElement('span'); viewTitle.classList.add('app-search-dialog-item-title'); viewTitle.textContent = `${category.label || category.key} — ${category.count}`; viewText.appendChild(viewTitle); viewLink.appendChild(viewText); viewLi.appendChild(viewLink); list.appendChild(viewLi); } group.appendChild(list); resultsEl.appendChild(group); }); if (hasResults) { setState('success'); if (viewAllLink) { const url = new URL(resultsPage.toString()); url.searchParams.set('search', query); viewAllLink.href = url.toString(); } } else { setState('empty'); if (emptyStateEl) { const p = emptyStateEl.querySelector('p'); if (p) { p.textContent = emptyStateEl.dataset.noResultsText || ''; } } } clearHighlight(); }; // --- Fetch --- const fetchResults = async (value) => { if (abortController) {abortController.abort();} abortController = new AbortController(); const { signal } = abortController; setState('loading'); const url = new URL(endpoint.toString()); url.searchParams.set('q', value); try { const data = await getJson(url.toString(), { signal }); if (Array.isArray(data)) { render(data, value); } else { warnOnce('FETCH_INVALID', 'Invalid response format for global search', { module: 'global-search', type: typeof data }); setState('error'); } } catch (err) { if (err.name === 'AbortError') {return;} warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err, status: err?.status }); setState('error'); } }; // --- Dialog open/close --- const openDialog = () => { if (dialog.open) {return;} dialog.showModal(); setState('empty'); resetEmptyState(); window.requestAnimationFrame(() => { if (input) { input.focus(); input.select(); } }); // If input already has a value, trigger search const currentValue = input ? input.value.trim() : ''; if (currentValue.length >= minLength) { fetchResults(currentValue); } }; const closeDialog = () => { if (!dialog.open) {return;} dialog.close(); clearHighlight(); }; const resetEmptyState = () => { if (!emptyStateEl) {return;} const p = emptyStateEl.querySelector('p'); if (p) { p.textContent = p.dataset.originalText || ''; } }; // Store original text for empty state if (emptyStateEl) { const p = emptyStateEl.querySelector('p'); if (p) {p.dataset.originalText = p.textContent;} } // --- Navigation --- const navigateToItem = (index) => { if (index < 0 || index >= flatItems.length) {return;} const item = flatItems[index]; const url = item.dataset.searchUrl || item.querySelector('a')?.href; if (url) { closeDialog(); window.location.href = url; } }; const submitSearch = () => { const value = input ? input.value.trim() : ''; if (value.length < minLength) {return;} const url = new URL(resultsPage.toString()); url.searchParams.set('search', value); closeDialog(); window.location.href = url.toString(); }; // --- Event handlers --- const onInput = () => { const value = input.value.trim(); if (value.length < minLength) { if (pending) {window.clearTimeout(pending); pending = null;} if (abortController) {abortController.abort(); abortController = null;} setState('empty'); resetEmptyState(); resultsEl.innerHTML = ''; flatItems = []; clearHighlight(); if (footerEl) {footerEl.hidden = true;} return; } if (pending) {window.clearTimeout(pending);} pending = window.setTimeout(() => { fetchResults(value).catch((err) => { warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err }); setState('error'); }); }, 300); }; const onInputKeydown = (event) => { if (event.key === 'ArrowDown') { event.preventDefault(); if (flatItems.length === 0) {return;} setHighlight(activeIndex < flatItems.length - 1 ? activeIndex + 1 : 0); return; } if (event.key === 'ArrowUp') { event.preventDefault(); if (flatItems.length === 0) {return;} setHighlight(activeIndex > 0 ? activeIndex - 1 : flatItems.length - 1); return; } if (event.key === 'Enter') { event.preventDefault(); if (activeIndex >= 0) { navigateToItem(activeIndex); } else { submitSearch(); } return; } }; const onDocumentKeydown = (event) => { if (!(event.metaKey || event.ctrlKey)) {return;} const key = event.key.toLowerCase(); if (key !== 'k' && key !== '/') {return;} if (isEditableTarget(event.target) && event.target !== input) {return;} event.preventDefault(); openDialog(); }; const onCloseClick = () => { closeDialog(); }; const onTriggerClick = () => { openDialog(); }; const onDialogClick = (event) => { // Close on backdrop click (click on itself, not its children) if (event.target === dialog) { closeDialog(); } }; const onResultClick = (event) => { const item = event.target.closest('[role="option"]'); if (!item) {return;} const link = item.querySelector('a'); if (link && event.target !== link) { closeDialog(); window.location.href = link.href; event.preventDefault(); } else if (link) { closeDialog(); } }; const onFormSubmit = (event) => { event.preventDefault(); if (activeIndex >= 0) { navigateToItem(activeIndex); } else { submitSearch(); } }; // --- Bind listeners --- if (input) { input.addEventListener('input', onInput); input.addEventListener('keydown', onInputKeydown); } document.addEventListener('keydown', onDocumentKeydown); if (closeButton) {closeButton.addEventListener('click', onCloseClick);} if (triggerButton) {triggerButton.addEventListener('click', onTriggerClick);} dialog.addEventListener('click', onDialogClick); if (resultsEl) {resultsEl.addEventListener('click', onResultClick);} const form = input ? input.closest('form') : null; if (form) {form.addEventListener('submit', onFormSubmit);} // --- Cleanup --- const destroy = () => { if (pending) {window.clearTimeout(pending);} if (abortController) {abortController.abort();} if (input) { input.removeEventListener('input', onInput); input.removeEventListener('keydown', onInputKeydown); } document.removeEventListener('keydown', onDocumentKeydown); if (closeButton) {closeButton.removeEventListener('click', onCloseClick);} if (triggerButton) {triggerButton.removeEventListener('click', onTriggerClick);} dialog.removeEventListener('click', onDialogClick); if (resultsEl) {resultsEl.removeEventListener('click', onResultClick);} if (form) {form.removeEventListener('submit', onFormSubmit);} delete dialog.dataset.globalSearchBound; delete dialog._globalSearchApi; }; const api = { destroy, openDialog, closeDialog }; dialog._globalSearchApi = api; return api; } function escapeHtml(text) { const div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } function safeCssClass(value) { return String(value).replace(/[^a-z0-9_-]/gi, ''); } function highlightText(el, text, query) { if (!query || query.length < 2) { el.textContent = text; return; } const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const parts = text.split(new RegExp(`(${escaped})`, 'gi')); if (parts.length === 1) { el.textContent = text; return; } const queryLower = query.toLowerCase(); parts.forEach((part) => { if (part.toLowerCase() === queryLower) { const mark = document.createElement('mark'); mark.textContent = part; el.appendChild(mark); } else if (part !== '') { el.appendChild(document.createTextNode(part)); } }); }