/** * Global search sidebar — debounced fetch with live result counts and Ctrl+K shortcut. */ import { warnOnce, resolveHost } from '../core/app-dom.js'; import { isEditableTarget } from '../core/app-form-utils.js'; import { requestAsidePanelOpen } from '../core/app-ui-channels.js'; export function initGlobalSearch(root = document, options = {}) { const host = resolveHost(root); const inputSelector = String(options.inputSelector || '#side-search').trim() || '#side-search'; const resultsSelector = String(options.resultsSelector || '[data-global-search-results]').trim() || '[data-global-search-results]'; const input = host.matches?.(inputSelector) ? host : host.querySelector(inputSelector); const resultsEl = host.matches?.(resultsSelector) ? host : host.querySelector(resultsSelector); if (!input || !resultsEl) {return { destroy: () => {} };} if (input.dataset.globalSearchBound === '1' && input._globalSearchApi) { return input._globalSearchApi; } input.dataset.globalSearchBound = '1'; const emptyStateEl = host.querySelector('[data-global-search-empty]'); const emptyHintEl = host.querySelector('[data-search-empty-hint-template]'); const shortcutEl = host.querySelector('[data-search-shortcut]'); const panel = host.matches?.('#aside-panel-search') ? host : host.querySelector('#aside-panel-search'); const toggleButton = host.querySelector('[data-search-details-toggle]'); const toggleIcon = host.querySelector('[data-search-details-icon]'); const appBase = String(options.appBase || document.baseURI || window.location.origin).trim() || document.baseURI; const endpoint = new URL('admin/search/data', appBase); const resultsPage = new URL('search', appBase); const minLength = 2; let pending = null; const shortcutLabel = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent) ? '⌘K' : 'Ctrl+K'; if (emptyHintEl) { const template = emptyHintEl.getAttribute('data-search-empty-hint-template') || ''; emptyHintEl.textContent = template.replace('{shortcut}', shortcutLabel); } if (shortcutEl) { shortcutEl.textContent = shortcutLabel; } const itemsByKey = new Map(); resultsEl.querySelectorAll('[data-search-key]').forEach((item) => { const key = item.getAttribute('data-search-key'); if (key) {itemsByKey.set(key, item);} }); if (itemsByKey.size === 0) { warnOnce('UI_EL_MISSING', 'No [data-search-key] elements found in results container', { module: 'global-search' }); } const setLoadingState = (value) => { resultsEl.querySelectorAll('[data-search-count]').forEach((el) => { el.textContent = value; }); }; const setIdleState = (idle) => { if (emptyStateEl) { emptyStateEl.hidden = !idle; } resultsEl.hidden = idle; }; const render = (items, query) => { const renderedKeys = new Set(); items.forEach((item) => { const li = itemsByKey.get(item.key); if (!li) { warnOnce('UI_EL_MISSING', `No element found for key: ${item.key}`, { module: 'global-search' }); return; } renderedKeys.add(item.key); const countEl = li.querySelector('[data-search-count]'); const link = li.querySelector('a'); const previewEl = li.querySelector('[data-search-preview]'); const base = item.key === 'pages' && item.url ? item.url : (li.getAttribute('data-search-base') || item.url); const url = new URL(base, appBase); if (item.key !== 'pages') { url.searchParams.set('search', query); } if (countEl) {countEl.textContent = item.count.toString();} if (link) { link.href = item.count > 0 ? url.toString() : base; link.setAttribute('aria-disabled', item.count > 0 ? 'false' : 'true'); } if (previewEl) { previewEl.innerHTML = ''; if (Array.isArray(item.items) && item.items.length > 0) { item.items.forEach((previewItem, idx) => { if (!previewItem || !previewItem.url) { warnOnce('UI_DATA_MISSING', `Preview item missing url at index ${idx} for key: ${item.key}`, { module: 'global-search', item: previewItem }); return; } const previewLi = document.createElement('li'); const previewLink = document.createElement('a'); previewLink.href = previewItem.url; previewLink.textContent = previewItem.label || ''; previewLi.appendChild(previewLink); previewEl.appendChild(previewLi); }); } } if (item.count > 0) { li.hidden = false; } else { li.hidden = true; } }); itemsByKey.forEach((li, key) => { if (renderedKeys.has(key)) { return; } const countEl = li.querySelector('[data-search-count]'); const link = li.querySelector('a'); const previewEl = li.querySelector('[data-search-preview]'); const base = li.getAttribute('data-search-base') || '#'; if (countEl) {countEl.textContent = '0';} if (link) { link.href = base; link.setAttribute('aria-disabled', 'true'); } if (previewEl) { previewEl.innerHTML = ''; } li.hidden = true; }); }; const clear = () => { setIdleState(true); setLoadingState('0'); resultsEl.querySelectorAll('a').forEach((link) => { link.setAttribute('aria-disabled', 'true'); const base = link.closest('[data-search-base]')?.getAttribute('data-search-base'); link.setAttribute('href', base || '#'); }); resultsEl.querySelectorAll('[data-search-preview]').forEach((list) => { list.innerHTML = ''; }); resultsEl.querySelectorAll('[data-search-key]').forEach((li) => { li.hidden = false; }); }; const fetchCounts = async (value) => { setIdleState(false); const url = new URL(endpoint.toString()); url.searchParams.set('q', value); setLoadingState('…'); const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } }); if (!response.ok) { warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, { module: 'global-search' }); clear(); 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 }); clear(); } }; const submitSearch = () => { const value = input.value.trim(); if (value.length < minLength) {return;} resultsPage.searchParams.set('search', value); window.location.href = resultsPage.toString(); }; const focusSearch = () => { window.requestAnimationFrame(() => { input.focus(); input.select(); }); }; const openSearch = () => { requestAsidePanelOpen('search', { fromUser: true }); focusSearch(); }; // --- Event handlers (named for cleanup) --- const onInput = () => { const value = input.value.trim(); if (value.length < minLength) { clear(); return; } if (pending) {window.clearTimeout(pending);} pending = window.setTimeout(() => { fetchCounts(value).catch((err) => { warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err }); clear(); }); }, 300); }; const onInputKeydown = (event) => { if (event.key !== 'Enter') {return;} event.preventDefault(); submitSearch(); }; 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(); openSearch(); }; const onEmptyStateClick = () => { focusSearch(); }; const onFormSubmit = (event) => { event.preventDefault(); submitSearch(); }; const onToggleClick = () => { panel.classList.toggle('search-details-hidden'); if (toggleIcon) { toggleIcon.classList.toggle('bi-dash-square'); toggleIcon.classList.toggle('bi-plus-square'); } }; // --- Bind listeners --- input.addEventListener('input', onInput); input.addEventListener('keydown', onInputKeydown); document.addEventListener('keydown', onDocumentKeydown); if (emptyStateEl) { emptyStateEl.addEventListener('click', onEmptyStateClick); } const form = input.closest('form'); if (form) { form.addEventListener('submit', onFormSubmit); } if (toggleButton && panel instanceof HTMLElement) { toggleButton.addEventListener('click', onToggleClick); } // Seed from current URL search param if present. const currentUrl = new URL(window.location.href); const preset = (currentUrl.searchParams.get('search') || '').trim(); if (preset && !input.value) { input.value = preset; } const initialValue = input.value.trim(); if (initialValue.length >= minLength) { fetchCounts(initialValue).catch((err) => { warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err }); clear(); }); } else { clear(); } // --- Cleanup --- const destroy = () => { if (pending) {window.clearTimeout(pending);} input.removeEventListener('input', onInput); input.removeEventListener('keydown', onInputKeydown); document.removeEventListener('keydown', onDocumentKeydown); if (emptyStateEl) { emptyStateEl.removeEventListener('click', onEmptyStateClick); } if (form) { form.removeEventListener('submit', onFormSubmit); } if (toggleButton && panel instanceof HTMLElement) { toggleButton.removeEventListener('click', onToggleClick); } delete input.dataset.globalSearchBound; delete input._globalSearchApi; }; const api = { destroy, openSearch, focusSearch }; input._globalSearchApi = api; return api; }