465 lines
15 KiB
JavaScript
465 lines
15 KiB
JavaScript
/**
|
|
* 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 <dialog> 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));
|
|
}
|
|
});
|
|
}
|