Files
breadcrumb-the-shire/web/js/components/app-global-search.js

470 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 { 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 response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' }, signal });
if (!response.ok) {
warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, { module: 'global-search' });
setState('error');
return;
}
const data = await response.json();
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 });
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));
}
});
}